fix: v0.2.3 OPFS数据恢复 + Aria WAL事务边界修复
This commit is contained in:
+5
-6
@@ -5,12 +5,11 @@ All notable changes to MetonaSqlark will be documented in this file.
|
|||||||
## [0.2.3] - 2026-07-27
|
## [0.2.3] - 2026-07-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **RB-Tree fixDelete 完整实现** — 补全标准红黑树删除修复(双黑问题),保证 O(log n) 性能
|
- **RB-Tree fixDelete 完整实现** — 补全标准红黑树删除修复,保证 O(log n)
|
||||||
- **LSM SSTable 缓存预热** — `init()` 时预加载所有 SSTable 数据到缓存,消除 cache miss 导致的数据丢失
|
- **LSM SSTable 缓存预热** — `init()` 预加载所有 SSTable,消除 cache miss
|
||||||
- **LZ4 往返正确性** — 重写 compress/decompress 为统一 token 格式,压缩解压完全可逆
|
- **OPFS 数据恢复** — `open()` 自动从 OPFS 文件加载已有表数据到内存
|
||||||
|
- **Aria WAL 事务恢复** — 两阶段恢复:仅回放已提交事务,未提交数据不回放
|
||||||
### Changed
|
- **LZ4 格式修复** — 重写 token 格式,消除中间字面量 token 歧义
|
||||||
- LZ4 压缩测试增加 3 个往返正确性验证用例
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Vendored
+54
-3
@@ -840,8 +840,10 @@ class OPFSEngine {
|
|||||||
await this.memoryCache.open(dbName, version);
|
await this.memoryCache.open(dbName, version);
|
||||||
// 获取 OPFS 根目录
|
// 获取 OPFS 根目录
|
||||||
this.root = await navigator.storage.getDirectory();
|
this.root = await navigator.storage.getDirectory();
|
||||||
// 创建数据库目录
|
// 创建/打开数据库目录
|
||||||
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
|
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
|
||||||
|
// 从 OPFS 恢复已有表数据到内存缓存
|
||||||
|
await this.loadExistingTables();
|
||||||
}
|
}
|
||||||
async close() {
|
async close() {
|
||||||
this.root = null;
|
this.root = null;
|
||||||
@@ -967,6 +969,39 @@ class OPFSEngine {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 从 OPFS 加载已有表数据到内存缓存 */
|
||||||
|
async loadExistingTables() {
|
||||||
|
if (!this.tablesDir)
|
||||||
|
return;
|
||||||
|
const dir = this.tablesDir;
|
||||||
|
for await (const [name] of dir.entries()) {
|
||||||
|
if (!name.endsWith('.json'))
|
||||||
|
continue;
|
||||||
|
const tableName = name.replace('.json', '');
|
||||||
|
try {
|
||||||
|
const data = await this.readTableData(tableName);
|
||||||
|
// 从数据中推断 schema(简化:从第一行提取列信息)
|
||||||
|
if (data.length > 0) {
|
||||||
|
const firstRow = data[0];
|
||||||
|
const columns = {};
|
||||||
|
for (const key of Object.keys(firstRow)) {
|
||||||
|
const val = firstRow[key];
|
||||||
|
const type = typeof val === 'number' ? 'number' :
|
||||||
|
typeof val === 'boolean' ? 'boolean' :
|
||||||
|
typeof val === 'object' ? 'json' : 'string';
|
||||||
|
columns[key] = { type, primaryKey: key === 'id' };
|
||||||
|
}
|
||||||
|
await this.memoryCache.createTable({ name: tableName, columns });
|
||||||
|
for (const row of data) {
|
||||||
|
await this.memoryCache.insert(tableName, [row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 单个文件损坏不影响其他表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/** 从 OPFS 加载表数据到内存缓存 */
|
/** 从 OPFS 加载表数据到内存缓存 */
|
||||||
async loadTableIntoMemory(tableName, schema) {
|
async loadTableIntoMemory(tableName, schema) {
|
||||||
await this.memoryCache.createTable(schema);
|
await this.memoryCache.createTable(schema);
|
||||||
@@ -3063,8 +3098,23 @@ class AriaEngine {
|
|||||||
await this.loadSchemas();
|
await this.loadSchemas();
|
||||||
// 6. 初始化 LSM(加载 SSTable 元数据)
|
// 6. 初始化 LSM(加载 SSTable 元数据)
|
||||||
await this.lsm.init();
|
await this.lsm.init();
|
||||||
// 7. WAL 恢复(恢复未刷盘的数据)
|
// 7. WAL 恢复(两阶段:先扫描事务边界,仅回放已提交事务)
|
||||||
await this.wal.recover((record) => this.applyWALRecord(record));
|
const committedTxns = new Set();
|
||||||
|
const allRecords = [];
|
||||||
|
await this.wal.recover((r) => allRecords.push(r));
|
||||||
|
// 第一遍:确定已提交事务
|
||||||
|
for (const r of allRecords) {
|
||||||
|
if (r.type === WALRecordType.COMMIT)
|
||||||
|
committedTxns.add(r.txnId);
|
||||||
|
if (r.type === WALRecordType.ROLLBACK)
|
||||||
|
committedTxns.delete(r.txnId);
|
||||||
|
}
|
||||||
|
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
|
||||||
|
for (const r of allRecords) {
|
||||||
|
if (r.txnId === 0 || committedTxns.has(r.txnId)) {
|
||||||
|
this.applyWALRecord(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
// 8. Checkpoint Manager(BufferPool 暂简化,使用 flush 替代)
|
// 8. Checkpoint Manager(BufferPool 暂简化,使用 flush 替代)
|
||||||
this.checkpointManager = new CheckpointManager(this.lsm, this.wal, { flushAll: async () => { await this.lsm.flush(); } }, this.config.checkpointInterval);
|
this.checkpointManager = new CheckpointManager(this.lsm, this.wal, { flushAll: async () => { await this.lsm.flush(); } }, this.config.checkpointInterval);
|
||||||
this.opened = true;
|
this.opened = true;
|
||||||
@@ -3546,6 +3596,7 @@ class AriaEngine {
|
|||||||
case WALRecordType.COMMIT:
|
case WALRecordType.COMMIT:
|
||||||
case WALRecordType.ROLLBACK:
|
case WALRecordType.ROLLBACK:
|
||||||
case WALRecordType.BEGIN:
|
case WALRecordType.BEGIN:
|
||||||
|
case WALRecordType.DROP_TABLE:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -622,6 +622,8 @@ declare class OPFSEngine implements IStorageEngine {
|
|||||||
private ensureDir;
|
private ensureDir;
|
||||||
private writeTableData;
|
private writeTableData;
|
||||||
private readTableData;
|
private readTableData;
|
||||||
|
/** 从 OPFS 加载已有表数据到内存缓存 */
|
||||||
|
private loadExistingTables;
|
||||||
/** 从 OPFS 加载表数据到内存缓存 */
|
/** 从 OPFS 加载表数据到内存缓存 */
|
||||||
loadTableIntoMemory(tableName: string, schema: TableSchema): Promise<void>;
|
loadTableIntoMemory(tableName: string, schema: TableSchema): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+54
-3
@@ -836,8 +836,10 @@ class OPFSEngine {
|
|||||||
await this.memoryCache.open(dbName, version);
|
await this.memoryCache.open(dbName, version);
|
||||||
// 获取 OPFS 根目录
|
// 获取 OPFS 根目录
|
||||||
this.root = await navigator.storage.getDirectory();
|
this.root = await navigator.storage.getDirectory();
|
||||||
// 创建数据库目录
|
// 创建/打开数据库目录
|
||||||
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
|
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
|
||||||
|
// 从 OPFS 恢复已有表数据到内存缓存
|
||||||
|
await this.loadExistingTables();
|
||||||
}
|
}
|
||||||
async close() {
|
async close() {
|
||||||
this.root = null;
|
this.root = null;
|
||||||
@@ -963,6 +965,39 @@ class OPFSEngine {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 从 OPFS 加载已有表数据到内存缓存 */
|
||||||
|
async loadExistingTables() {
|
||||||
|
if (!this.tablesDir)
|
||||||
|
return;
|
||||||
|
const dir = this.tablesDir;
|
||||||
|
for await (const [name] of dir.entries()) {
|
||||||
|
if (!name.endsWith('.json'))
|
||||||
|
continue;
|
||||||
|
const tableName = name.replace('.json', '');
|
||||||
|
try {
|
||||||
|
const data = await this.readTableData(tableName);
|
||||||
|
// 从数据中推断 schema(简化:从第一行提取列信息)
|
||||||
|
if (data.length > 0) {
|
||||||
|
const firstRow = data[0];
|
||||||
|
const columns = {};
|
||||||
|
for (const key of Object.keys(firstRow)) {
|
||||||
|
const val = firstRow[key];
|
||||||
|
const type = typeof val === 'number' ? 'number' :
|
||||||
|
typeof val === 'boolean' ? 'boolean' :
|
||||||
|
typeof val === 'object' ? 'json' : 'string';
|
||||||
|
columns[key] = { type, primaryKey: key === 'id' };
|
||||||
|
}
|
||||||
|
await this.memoryCache.createTable({ name: tableName, columns });
|
||||||
|
for (const row of data) {
|
||||||
|
await this.memoryCache.insert(tableName, [row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 单个文件损坏不影响其他表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/** 从 OPFS 加载表数据到内存缓存 */
|
/** 从 OPFS 加载表数据到内存缓存 */
|
||||||
async loadTableIntoMemory(tableName, schema) {
|
async loadTableIntoMemory(tableName, schema) {
|
||||||
await this.memoryCache.createTable(schema);
|
await this.memoryCache.createTable(schema);
|
||||||
@@ -3059,8 +3094,23 @@ class AriaEngine {
|
|||||||
await this.loadSchemas();
|
await this.loadSchemas();
|
||||||
// 6. 初始化 LSM(加载 SSTable 元数据)
|
// 6. 初始化 LSM(加载 SSTable 元数据)
|
||||||
await this.lsm.init();
|
await this.lsm.init();
|
||||||
// 7. WAL 恢复(恢复未刷盘的数据)
|
// 7. WAL 恢复(两阶段:先扫描事务边界,仅回放已提交事务)
|
||||||
await this.wal.recover((record) => this.applyWALRecord(record));
|
const committedTxns = new Set();
|
||||||
|
const allRecords = [];
|
||||||
|
await this.wal.recover((r) => allRecords.push(r));
|
||||||
|
// 第一遍:确定已提交事务
|
||||||
|
for (const r of allRecords) {
|
||||||
|
if (r.type === WALRecordType.COMMIT)
|
||||||
|
committedTxns.add(r.txnId);
|
||||||
|
if (r.type === WALRecordType.ROLLBACK)
|
||||||
|
committedTxns.delete(r.txnId);
|
||||||
|
}
|
||||||
|
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
|
||||||
|
for (const r of allRecords) {
|
||||||
|
if (r.txnId === 0 || committedTxns.has(r.txnId)) {
|
||||||
|
this.applyWALRecord(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
// 8. Checkpoint Manager(BufferPool 暂简化,使用 flush 替代)
|
// 8. Checkpoint Manager(BufferPool 暂简化,使用 flush 替代)
|
||||||
this.checkpointManager = new CheckpointManager(this.lsm, this.wal, { flushAll: async () => { await this.lsm.flush(); } }, this.config.checkpointInterval);
|
this.checkpointManager = new CheckpointManager(this.lsm, this.wal, { flushAll: async () => { await this.lsm.flush(); } }, this.config.checkpointInterval);
|
||||||
this.opened = true;
|
this.opened = true;
|
||||||
@@ -3542,6 +3592,7 @@ class AriaEngine {
|
|||||||
case WALRecordType.COMMIT:
|
case WALRecordType.COMMIT:
|
||||||
case WALRecordType.ROLLBACK:
|
case WALRecordType.ROLLBACK:
|
||||||
case WALRecordType.BEGIN:
|
case WALRecordType.BEGIN:
|
||||||
|
case WALRecordType.DROP_TABLE:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+54
-3
@@ -842,8 +842,10 @@
|
|||||||
await this.memoryCache.open(dbName, version);
|
await this.memoryCache.open(dbName, version);
|
||||||
// 获取 OPFS 根目录
|
// 获取 OPFS 根目录
|
||||||
this.root = await navigator.storage.getDirectory();
|
this.root = await navigator.storage.getDirectory();
|
||||||
// 创建数据库目录
|
// 创建/打开数据库目录
|
||||||
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
|
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
|
||||||
|
// 从 OPFS 恢复已有表数据到内存缓存
|
||||||
|
await this.loadExistingTables();
|
||||||
}
|
}
|
||||||
async close() {
|
async close() {
|
||||||
this.root = null;
|
this.root = null;
|
||||||
@@ -969,6 +971,39 @@
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 从 OPFS 加载已有表数据到内存缓存 */
|
||||||
|
async loadExistingTables() {
|
||||||
|
if (!this.tablesDir)
|
||||||
|
return;
|
||||||
|
const dir = this.tablesDir;
|
||||||
|
for await (const [name] of dir.entries()) {
|
||||||
|
if (!name.endsWith('.json'))
|
||||||
|
continue;
|
||||||
|
const tableName = name.replace('.json', '');
|
||||||
|
try {
|
||||||
|
const data = await this.readTableData(tableName);
|
||||||
|
// 从数据中推断 schema(简化:从第一行提取列信息)
|
||||||
|
if (data.length > 0) {
|
||||||
|
const firstRow = data[0];
|
||||||
|
const columns = {};
|
||||||
|
for (const key of Object.keys(firstRow)) {
|
||||||
|
const val = firstRow[key];
|
||||||
|
const type = typeof val === 'number' ? 'number' :
|
||||||
|
typeof val === 'boolean' ? 'boolean' :
|
||||||
|
typeof val === 'object' ? 'json' : 'string';
|
||||||
|
columns[key] = { type, primaryKey: key === 'id' };
|
||||||
|
}
|
||||||
|
await this.memoryCache.createTable({ name: tableName, columns });
|
||||||
|
for (const row of data) {
|
||||||
|
await this.memoryCache.insert(tableName, [row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 单个文件损坏不影响其他表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/** 从 OPFS 加载表数据到内存缓存 */
|
/** 从 OPFS 加载表数据到内存缓存 */
|
||||||
async loadTableIntoMemory(tableName, schema) {
|
async loadTableIntoMemory(tableName, schema) {
|
||||||
await this.memoryCache.createTable(schema);
|
await this.memoryCache.createTable(schema);
|
||||||
@@ -3065,8 +3100,23 @@
|
|||||||
await this.loadSchemas();
|
await this.loadSchemas();
|
||||||
// 6. 初始化 LSM(加载 SSTable 元数据)
|
// 6. 初始化 LSM(加载 SSTable 元数据)
|
||||||
await this.lsm.init();
|
await this.lsm.init();
|
||||||
// 7. WAL 恢复(恢复未刷盘的数据)
|
// 7. WAL 恢复(两阶段:先扫描事务边界,仅回放已提交事务)
|
||||||
await this.wal.recover((record) => this.applyWALRecord(record));
|
const committedTxns = new Set();
|
||||||
|
const allRecords = [];
|
||||||
|
await this.wal.recover((r) => allRecords.push(r));
|
||||||
|
// 第一遍:确定已提交事务
|
||||||
|
for (const r of allRecords) {
|
||||||
|
if (r.type === WALRecordType.COMMIT)
|
||||||
|
committedTxns.add(r.txnId);
|
||||||
|
if (r.type === WALRecordType.ROLLBACK)
|
||||||
|
committedTxns.delete(r.txnId);
|
||||||
|
}
|
||||||
|
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
|
||||||
|
for (const r of allRecords) {
|
||||||
|
if (r.txnId === 0 || committedTxns.has(r.txnId)) {
|
||||||
|
this.applyWALRecord(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
// 8. Checkpoint Manager(BufferPool 暂简化,使用 flush 替代)
|
// 8. Checkpoint Manager(BufferPool 暂简化,使用 flush 替代)
|
||||||
this.checkpointManager = new CheckpointManager(this.lsm, this.wal, { flushAll: async () => { await this.lsm.flush(); } }, this.config.checkpointInterval);
|
this.checkpointManager = new CheckpointManager(this.lsm, this.wal, { flushAll: async () => { await this.lsm.flush(); } }, this.config.checkpointInterval);
|
||||||
this.opened = true;
|
this.opened = true;
|
||||||
@@ -3548,6 +3598,7 @@
|
|||||||
case WALRecordType.COMMIT:
|
case WALRecordType.COMMIT:
|
||||||
case WALRecordType.ROLLBACK:
|
case WALRecordType.ROLLBACK:
|
||||||
case WALRecordType.BEGIN:
|
case WALRecordType.BEGIN:
|
||||||
|
case WALRecordType.DROP_TABLE:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -133,8 +133,21 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
// 6. 初始化 LSM(加载 SSTable 元数据)
|
// 6. 初始化 LSM(加载 SSTable 元数据)
|
||||||
await this.lsm.init();
|
await this.lsm.init();
|
||||||
|
|
||||||
// 7. WAL 恢复(恢复未刷盘的数据)
|
// 7. WAL 恢复(两阶段:先扫描事务边界,仅回放已提交事务)
|
||||||
await this.wal.recover((record) => this.applyWALRecord(record));
|
const committedTxns = new Set<number>();
|
||||||
|
const allRecords: WALRecord[] = [];
|
||||||
|
await this.wal.recover((r) => allRecords.push(r));
|
||||||
|
// 第一遍:确定已提交事务
|
||||||
|
for (const r of allRecords) {
|
||||||
|
if (r.type === WALRecordType.COMMIT) committedTxns.add(r.txnId);
|
||||||
|
if (r.type === WALRecordType.ROLLBACK) committedTxns.delete(r.txnId);
|
||||||
|
}
|
||||||
|
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
|
||||||
|
for (const r of allRecords) {
|
||||||
|
if (r.txnId === 0 || committedTxns.has(r.txnId)) {
|
||||||
|
this.applyWALRecord(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 8. Checkpoint Manager(BufferPool 暂简化,使用 flush 替代)
|
// 8. Checkpoint Manager(BufferPool 暂简化,使用 flush 替代)
|
||||||
this.checkpointManager = new CheckpointManager(
|
this.checkpointManager = new CheckpointManager(
|
||||||
@@ -668,6 +681,7 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
case WALRecordType.COMMIT:
|
case WALRecordType.COMMIT:
|
||||||
case WALRecordType.ROLLBACK:
|
case WALRecordType.ROLLBACK:
|
||||||
case WALRecordType.BEGIN:
|
case WALRecordType.BEGIN:
|
||||||
|
case WALRecordType.DROP_TABLE:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-1
@@ -34,8 +34,11 @@ export class OPFSEngine implements IStorageEngine {
|
|||||||
// 获取 OPFS 根目录
|
// 获取 OPFS 根目录
|
||||||
this.root = await navigator.storage.getDirectory();
|
this.root = await navigator.storage.getDirectory();
|
||||||
|
|
||||||
// 创建数据库目录
|
// 创建/打开数据库目录
|
||||||
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
|
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
|
||||||
|
|
||||||
|
// 从 OPFS 恢复已有表数据到内存缓存
|
||||||
|
await this.loadExistingTables();
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
@@ -180,6 +183,37 @@ export class OPFSEngine implements IStorageEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从 OPFS 加载已有表数据到内存缓存 */
|
||||||
|
private async loadExistingTables(): Promise<void> {
|
||||||
|
if (!this.tablesDir) return;
|
||||||
|
const dir = this.tablesDir as any;
|
||||||
|
for await (const [name] of dir.entries()) {
|
||||||
|
if (!name.endsWith('.json')) continue;
|
||||||
|
const tableName = name.replace('.json', '');
|
||||||
|
try {
|
||||||
|
const data = await this.readTableData(tableName);
|
||||||
|
// 从数据中推断 schema(简化:从第一行提取列信息)
|
||||||
|
if (data.length > 0) {
|
||||||
|
const firstRow = data[0];
|
||||||
|
const columns: Record<string, any> = {};
|
||||||
|
for (const key of Object.keys(firstRow)) {
|
||||||
|
const val = firstRow[key];
|
||||||
|
const type = typeof val === 'number' ? 'number' :
|
||||||
|
typeof val === 'boolean' ? 'boolean' :
|
||||||
|
typeof val === 'object' ? 'json' : 'string';
|
||||||
|
columns[key] = { type, primaryKey: key === 'id' };
|
||||||
|
}
|
||||||
|
await this.memoryCache.createTable({ name: tableName, columns });
|
||||||
|
for (const row of data) {
|
||||||
|
await this.memoryCache.insert(tableName, [row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 单个文件损坏不影响其他表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 从 OPFS 加载表数据到内存缓存 */
|
/** 从 OPFS 加载表数据到内存缓存 */
|
||||||
async loadTableIntoMemory(tableName: string, schema: TableSchema): Promise<void> {
|
async loadTableIntoMemory(tableName: string, schema: TableSchema): Promise<void> {
|
||||||
await this.memoryCache.createTable(schema);
|
await this.memoryCache.createTable(schema);
|
||||||
|
|||||||
Reference in New Issue
Block a user