From a944bd9b3c5b9fa33e0a8970afa50b30c7b30895 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:00:29 +0000 Subject: [PATCH 1/2] Initial plan From 4ede581388abe01a35dcc470480246838bb8d3c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:08:36 +0000 Subject: [PATCH 2/2] feat: Add SqliteDataSet POC backed by sql.js Agent-Logs-Url: https://github.com/Blockception/minecraft-bedrock-language-server/sessions/1fea08ef-cdcf-4712-94b4-a5e4a6bc1f0d Co-authored-by: DaanV2 <2393905+DaanV2@users.noreply.github.com> --- package-lock.json | 28 +- packages/bedrock-project/package.json | 4 +- packages/bedrock-project/src/types/index.ts | 1 + .../src/types/sqlite-data-set.test.ts | 241 ++++++++++++++++++ .../src/types/sqlite-data-set.ts | 194 ++++++++++++++ 5 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 packages/bedrock-project/src/types/sqlite-data-set.test.ts create mode 100644 packages/bedrock-project/src/types/sqlite-data-set.ts diff --git a/package-lock.json b/package-lock.json index 3ee46fa2..dbe8af6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2669,6 +2669,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2792,6 +2799,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/sql.js": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.10.tgz", + "integrity": "sha512-E7XnsrWm01Uvp0/0+iRI9ZwO/BvKyiiHUpcVKJenVVH2pUdZndsgQ5BWXNxKaEO+bkKbvU29Ky9o21juMip1ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -11423,6 +11441,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -13478,12 +13502,14 @@ "bc-minecraft-bedrock-types": "*", "bc-minecraft-bedrock-vanilla-data": "*", "bc-minecraft-molang": "*", - "bc-minecraft-project": "*" + "bc-minecraft-project": "*", + "sql.js": "^1.12.0" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/eslint__js": "^9.14.0", "@types/node": "^25.5.0", + "@types/sql.js": "^1.4.9", "eslint": "^10.1.0", "rimraf": "^6.1.3", "ts-node": "^10.9.1", diff --git a/packages/bedrock-project/package.json b/packages/bedrock-project/package.json index 81efec53..42f62990 100644 --- a/packages/bedrock-project/package.json +++ b/packages/bedrock-project/package.json @@ -44,6 +44,7 @@ "@eslint/js": "^10.0.1", "@types/eslint__js": "^9.14.0", "@types/node": "^25.5.0", + "@types/sql.js": "^1.4.9", "eslint": "^10.1.0", "rimraf": "^6.1.3", "ts-node": "^10.9.1", @@ -56,6 +57,7 @@ "bc-minecraft-bedrock-types": "*", "bc-minecraft-bedrock-vanilla-data": "*", "bc-minecraft-molang": "*", - "bc-minecraft-project": "*" + "bc-minecraft-project": "*", + "sql.js": "^1.12.0" } } diff --git a/packages/bedrock-project/src/types/index.ts b/packages/bedrock-project/src/types/index.ts index bd8f04b3..3afacc3c 100644 --- a/packages/bedrock-project/src/types/index.ts +++ b/packages/bedrock-project/src/types/index.ts @@ -3,6 +3,7 @@ export * from './container'; export * from './data-set'; export * from './data-set-connector'; +export * from './sqlite-data-set'; export * from './documentation'; export * from './i-data-set'; export * from './json-path'; diff --git a/packages/bedrock-project/src/types/sqlite-data-set.test.ts b/packages/bedrock-project/src/types/sqlite-data-set.test.ts new file mode 100644 index 00000000..c23270fc --- /dev/null +++ b/packages/bedrock-project/src/types/sqlite-data-set.test.ts @@ -0,0 +1,241 @@ +import { DocumentLocation, Location } from 'bc-minecraft-bedrock-shared'; +import { BaseObject } from 'bc-minecraft-bedrock-types'; +import initSqlJs from 'sql.js'; +import { SqliteDataSet } from './sqlite-data-set'; + +// Use the same concrete fake type as data-set.test.ts so the two test suites +// are directly comparable. +type TestObject = BaseObject; + +namespace TestObject { + export function create( + id: string, + uri: string, + position: DocumentLocation | undefined = undefined, + ): TestObject { + return { + id, + location: Location.create(uri, position), + documentation: 'custom object: ' + id, + }; + } +} + +const dataID = 'example.id'; +const dataUri = 'c:\\project\\bp\\loot_tables\\example.data.json'; +const dataFolder = 'c:\\project\\bp\\loot_tables'; +const dataItem = TestObject.create(dataID, dataUri); + +describe('SqliteDataSet', () => { + let dataset: SqliteDataSet; + + // Each test gets a fresh in-memory database for full isolation. + beforeEach(async () => { + dataset = await SqliteDataSet.create('test'); + }); + + // ── 1. set + get ────────────────────────────────────────────────────────── + + it('set + get round-trips a typed object through JSON', async () => { + dataset.set(dataItem); + + const result = dataset.get(dataID); + expect(result).toEqual(dataItem); + }); + + // ── 2. has ──────────────────────────────────────────────────────────────── + + it('has returns true when item exists, false when it does not', () => { + expect(dataset.has(dataID)).toBeFalsy(); + + dataset.set(dataItem); + + expect(dataset.has(dataID)).toBeTruthy(); + expect(dataset.has('nonexistent')).toBeFalsy(); + }); + + // ── 3. delete ───────────────────────────────────────────────────────────── + + it('delete removes by id and returns true; returns false when not found', () => { + dataset.set(dataItem); + + expect(dataset.has(dataID)).toBeTruthy(); + expect(dataset.delete(dataID)).toBeTruthy(); + expect(dataset.has(dataID)).toBeFalsy(); + + // Deleting a non-existent id should return false. + expect(dataset.delete(dataID)).toBeFalsy(); + }); + + it('delete accepts an Identifiable object', () => { + dataset.set(dataItem); + + expect(dataset.delete(dataItem)).toBeTruthy(); + expect(dataset.has(dataItem)).toBeFalsy(); + }); + + // ── 4. deleteFile ───────────────────────────────────────────────────────── + + it('deleteFile removes all items with matching uri', () => { + const other = TestObject.create('other.id', 'c:\\project\\bp\\other.json'); + dataset.set(dataItem); + dataset.set(other); + + expect(dataset.deleteFile(dataUri)).toBeTruthy(); + expect(dataset.has(dataID)).toBeFalsy(); + + // Item with a different uri should survive. + expect(dataset.has('other.id')).toBeTruthy(); + }); + + it('deleteFile returns false when no items match', () => { + expect(dataset.deleteFile('file:///nonexistent')).toBeFalsy(); + }); + + // ── 5. deleteFolder ─────────────────────────────────────────────────────── + + it('deleteFolder removes all items whose uri starts with the folder path', () => { + const sibling = TestObject.create('sibling.id', 'c:\\project\\bp\\loot_tables\\other.json'); + const outside = TestObject.create('outside.id', 'c:\\project\\rp\\textures\\pig.png'); + + dataset.set(dataItem); // uri = dataUri (inside dataFolder) + dataset.set(sibling); // also inside 'c:\\project\\bp' + dataset.set(outside); // different root + + expect(dataset.deleteFolder(dataFolder)).toBeTruthy(); + + expect(dataset.has(dataID)).toBeFalsy(); + expect(dataset.has('sibling.id')).toBeFalsy(); + // Item outside the folder should survive. + expect(dataset.has('outside.id')).toBeTruthy(); + }); + + it('deleteFolder returns false when no items match', () => { + expect(dataset.deleteFolder('file:///nonexistent/')).toBeFalsy(); + }); + + // ── 6. forEach ──────────────────────────────────────────────────────────── + + it('forEach iterates all items', () => { + dataset.set(TestObject.create('a', 'b')); + dataset.set(TestObject.create('c', 'b')); + dataset.set(TestObject.create('d', 'b')); + dataset.set(TestObject.create('e', 'b')); + + let count = 0; + dataset.forEach((item) => { + if (item) count++; + else throw new Error('Item was undefined'); + }); + + expect(count).toEqual(4); + }); + + it('forEach passes thisArg correctly', () => { + dataset.set(dataItem); + const ctx = { called: false }; + + dataset.forEach(function (this: typeof ctx) { + this.called = true; + }, ctx); + + expect(ctx.called).toBeTruthy(); + }); + + // ── 7. find ─────────────────────────────────────────────────────────────── + + it('find returns the matching item', () => { + dataset.set(dataItem); + dataset.set(TestObject.create('another.id', dataUri)); + + const found = dataset.find((item) => item.id === dataID); + expect(found).toEqual(dataItem); + }); + + it('find returns undefined when predicate never matches', () => { + dataset.set(dataItem); + + const found = dataset.find(() => false); + expect(found).toBeUndefined(); + }); + + // ── 8. count ────────────────────────────────────────────────────────────── + + it('count returns the number of stored items', () => { + expect(dataset.count()).toEqual(0); + + dataset.set(TestObject.create('a', 'b')); + dataset.set(TestObject.create('c', 'b')); + dataset.set(TestObject.create('d', 'b')); + dataset.set(dataItem); + + expect(dataset.count()).toEqual(4); + }); + + // ── 9. clear ────────────────────────────────────────────────────────────── + + it('clear removes all items', () => { + dataset.set(dataItem); + dataset.set(TestObject.create('a', 'b')); + + expect(dataset.count()).toBeGreaterThan(0); + + dataset.clear(); + + expect(dataset.count()).toEqual(0); + expect(dataset.has(dataID)).toBeFalsy(); + }); + + // ── 10. set with array ──────────────────────────────────────────────────── + + it('set with array inserts multiple items at once', () => { + const items = [ + TestObject.create('x1', 'file:///a.json'), + TestObject.create('x2', 'file:///b.json'), + TestObject.create('x3', 'file:///c.json'), + ]; + + dataset.set(items); + + expect(dataset.count()).toEqual(3); + expect(dataset.has('x1')).toBeTruthy(); + expect(dataset.has('x2')).toBeTruthy(); + expect(dataset.has('x3')).toBeTruthy(); + }); + + // ── 11. upsert behaviour ────────────────────────────────────────────────── + + it('setting the same id twice keeps the latest value', () => { + const updated = TestObject.create(dataID, 'new-uri'); + + dataset.set(dataItem); + dataset.set(updated); + + // Should still be one row. + expect(dataset.count()).toEqual(1); + + const result = dataset.get(dataID); + expect(result).toEqual(updated); + expect(result).not.toEqual(dataItem); + }); + + // ── shared DB across datasets ───────────────────────────────────────────── + + it('two datasets can share the same Database instance', async () => { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + + const ds1 = await SqliteDataSet.create('table_one', db); + const ds2 = await SqliteDataSet.create('table_two', db); + + ds1.set(dataItem); + ds2.set(TestObject.create('other.id', 'other.uri')); + + // Each dataset is isolated to its own table. + expect(ds1.has(dataID)).toBeTruthy(); + expect(ds1.has('other.id')).toBeFalsy(); + + expect(ds2.has('other.id')).toBeTruthy(); + expect(ds2.has(dataID)).toBeFalsy(); + }); +}); diff --git a/packages/bedrock-project/src/types/sqlite-data-set.ts b/packages/bedrock-project/src/types/sqlite-data-set.ts new file mode 100644 index 00000000..6c8680c2 --- /dev/null +++ b/packages/bedrock-project/src/types/sqlite-data-set.ts @@ -0,0 +1,194 @@ +import { Identifiable, Locatable } from 'bc-minecraft-bedrock-shared'; +import initSqlJs, { Database } from 'sql.js'; +import { DataSetBase } from './data-set'; +import { IDataSet } from './i-data-set'; + +/** + * A drop-in replacement for `DataSet` backed by an in-memory SQLite database + * (sql.js, SQLite compiled to WASM). Works on all platforms including vscode.dev + * (browser/web extension) with no native binaries. + * + * Schema (one table per dataset): + * ```sql + * CREATE TABLE ( + * id TEXT PRIMARY KEY, + * uri TEXT NOT NULL, + * data TEXT NOT NULL + * ); + * CREATE INDEX idx__uri ON (uri); + * ``` + * + * The full typed object is serialised as JSON in the `data` column. + * The `id` and `uri` columns are stored separately so that `deleteFile` + * and `deleteFolder` can use efficient SQL `WHERE` clauses instead of + * iterating every row. + */ +export class SqliteDataSet implements DataSetBase, IDataSet { + private _db: Database; + private _table: string; + + /** Use {@link SqliteDataSet.create} instead of calling this directly. */ + private constructor(db: Database, table: string) { + this._db = db; + this._table = table; + } + + /** + * Async factory — creates (or reuses) a table in the given SQLite database + * and returns a ready-to-use `SqliteDataSet`. + * + * @param tableName Name for the SQLite table (e.g. `'bp_entities'`). + * @param db Optional: share a single `Database` instance across + * multiple datasets. If omitted a new in-memory DB is + * created for this dataset. + * + * @example + * ```ts + * const entities = await SqliteDataSet.create('bp_entities'); + * ``` + */ + static async create( + tableName: string, + db?: Database, + ): Promise> { + if (!db) { + // Initialise sql.js — no `locateFile` override needed for Node.js / Jest; + // the default loader resolves the WASM binary from node_modules automatically. + const SQL = await initSqlJs(); + db = new SQL.Database(); + } + + // Create the table and URI index if they don't exist yet. + db.run(` + CREATE TABLE IF NOT EXISTS "${tableName}" ( + id TEXT PRIMARY KEY, + uri TEXT NOT NULL, + data TEXT NOT NULL + ) + `); + db.run(` + CREATE INDEX IF NOT EXISTS "idx_${tableName}_uri" + ON "${tableName}"(uri) + `); + + return new SqliteDataSet(db, tableName); + } + + // ── DataSetBase ───────────────────────────────────────────────────────────── + + /** Removes every row from the table. */ + clear(): void { + this._db.run(`DELETE FROM "${this._table}"`); + } + + /** + * Removes the item with the given id. + * @returns `true` if a row was deleted, `false` if the id was not found. + */ + delete(key: string | Identifiable): boolean { + const id = Identifiable.getId(key); + this._db.run(`DELETE FROM "${this._table}" WHERE id = ?`, [id]); + // `getRowsModified()` returns the number of rows changed by the last statement. + return this._db.getRowsModified() > 0; + } + + /** + * Removes all items that were loaded from the given file URI. + * Equivalent to `DELETE WHERE uri = ?`. + * @returns `true` if at least one row was deleted. + */ + deleteFile(uri: string): boolean { + this._db.run(`DELETE FROM "${this._table}" WHERE uri = ?`, [uri]); + return this._db.getRowsModified() > 0; + } + + /** + * Removes all items whose `location.uri` starts with the given folder URI. + * Implemented as `DELETE WHERE uri LIKE ? || '%'`. + * + * This mirrors the behaviour of `DataSet.deleteFolder` and is safe for file + * URIs because they don't contain SQL wildcard characters (`%` or `_`). + * @returns `true` if at least one row was deleted. + */ + deleteFolder(uri: string): boolean { + this._db.run(`DELETE FROM "${this._table}" WHERE uri LIKE ? || '%'`, [uri]); + return this._db.getRowsModified() > 0; + } + + /** Returns `true` if an item with the given id exists in the dataset. */ + has(key: string | Identifiable): boolean { + const id = Identifiable.getId(key); + const result = this._db.exec(`SELECT 1 FROM "${this._table}" WHERE id = ? LIMIT 1`, [id]); + return result.length > 0 && result[0].values.length > 0; + } + + // ── IDataSet ───────────────────────────────────────────────────────────── + + /** + * Retrieves the item with the given id, deserialised from JSON. + * Returns `undefined` if no matching row exists. + */ + get(key: string | Identifiable): T | undefined { + const id = Identifiable.getId(key); + const result = this._db.exec(`SELECT data FROM "${this._table}" WHERE id = ? LIMIT 1`, [id]); + if (result.length === 0 || result[0].values.length === 0) return undefined; + return JSON.parse(result[0].values[0][0] as string) as T; + } + + /** + * Iterates over every item in the dataset. + * @param callbackfn Called for each deserialised item. + * @param thisArg Optional `this` context for the callback. + */ + forEach(callbackfn: (value: T) => void, thisArg?: any): void { + const result = this._db.exec(`SELECT data FROM "${this._table}"`); + if (result.length === 0) return; + for (const row of result[0].values) { + callbackfn.call(thisArg, JSON.parse(row[0] as string) as T); + } + } + + /** + * Returns the first item for which `predicate` returns `true`, or `undefined`. + * @param predicate Receives each deserialised item and its id string. + */ + find(predicate: (value: T, key: string) => boolean): T | undefined { + const result = this._db.exec(`SELECT id, data FROM "${this._table}"`); + if (result.length === 0) return undefined; + for (const row of result[0].values) { + const id = row[0] as string; + const item = JSON.parse(row[1] as string) as T; + if (predicate(item, id)) return item; + } + return undefined; + } + + // ── Extra (matches DataSet) ─────────────────────────────────────────────── + + /** + * Inserts or replaces one or more items. + * Uses `INSERT OR REPLACE` so duplicate ids overwrite the previous value (upsert). + * + * @param value A single item, an array of items, or `undefined` (no-op). + * @returns `this` for method chaining. + */ + set(value: T | T[] | undefined): this { + if (!value) return this; + + const items = Array.isArray(value) ? value : [value]; + for (const item of items) { + this._db.run( + `INSERT OR REPLACE INTO "${this._table}" (id, uri, data) VALUES (?, ?, ?)`, + [item.id, item.location.uri, JSON.stringify(item)], + ); + } + return this; + } + + /** Returns the total number of items currently stored in the dataset. */ + count(): number { + const result = this._db.exec(`SELECT COUNT(*) FROM "${this._table}"`); + if (result.length === 0) return 0; + return result[0].values[0][0] as number; + } +}