@verdant-web/server 3.0.4 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +650 -21
  2. package/dist/esm/ServerLibrary.test.js +10 -0
  3. package/dist/esm/ServerLibrary.test.js.map +1 -1
  4. package/dist/esm/storage/index.d.ts +1 -0
  5. package/dist/esm/storage/index.js +1 -0
  6. package/dist/esm/storage/index.js.map +1 -1
  7. package/dist/esm/storage/sql/database.d.ts +9 -0
  8. package/dist/esm/storage/sql/database.js +24 -0
  9. package/dist/esm/storage/sql/database.js.map +1 -0
  10. package/dist/esm/storage/sql/sqlStorage.d.ts +2 -1
  11. package/dist/esm/storage/sql/sqlStorage.js +5 -13
  12. package/dist/esm/storage/sql/sqlStorage.js.map +1 -1
  13. package/dist/esm/storage/sql/tables.d.ts +2 -0
  14. package/dist/esm/storage/sqlShard/Databases.d.ts +21 -0
  15. package/dist/esm/storage/sqlShard/Databases.js +64 -0
  16. package/dist/esm/storage/sqlShard/Databases.js.map +1 -0
  17. package/dist/esm/storage/sqlShard/SqlBaselines.d.ts +23 -0
  18. package/dist/esm/storage/sqlShard/SqlBaselines.js +98 -0
  19. package/dist/esm/storage/sqlShard/SqlBaselines.js.map +1 -0
  20. package/dist/esm/storage/sqlShard/SqlFileMetadata.d.ts +18 -0
  21. package/dist/esm/storage/sqlShard/SqlFileMetadata.js +70 -0
  22. package/dist/esm/storage/sqlShard/SqlFileMetadata.js.map +1 -0
  23. package/dist/esm/storage/sqlShard/SqlOperations.d.ts +18 -0
  24. package/dist/esm/storage/sqlShard/SqlOperations.js +104 -0
  25. package/dist/esm/storage/sqlShard/SqlOperations.js.map +1 -0
  26. package/dist/esm/storage/sqlShard/SqlReplicas.d.ts +32 -0
  27. package/dist/esm/storage/sqlShard/SqlReplicas.js +164 -0
  28. package/dist/esm/storage/sqlShard/SqlReplicas.js.map +1 -0
  29. package/dist/esm/storage/sqlShard/_testData/unifiedData.d.ts +30 -0
  30. package/dist/esm/storage/sqlShard/_testData/unifiedData.js +42 -0
  31. package/dist/esm/storage/sqlShard/_testData/unifiedData.js.map +1 -0
  32. package/dist/esm/storage/sqlShard/database.d.ts +6 -0
  33. package/dist/esm/storage/sqlShard/database.js +28 -0
  34. package/dist/esm/storage/sqlShard/database.js.map +1 -0
  35. package/dist/esm/storage/sqlShard/migrations/v1.d.ts +3 -0
  36. package/dist/esm/storage/sqlShard/migrations/v1.js +48 -0
  37. package/dist/esm/storage/sqlShard/migrations/v1.js.map +1 -0
  38. package/dist/esm/storage/sqlShard/migrations.d.ts +5 -0
  39. package/dist/esm/storage/sqlShard/migrations.js +3 -0
  40. package/dist/esm/storage/sqlShard/migrations.js.map +1 -0
  41. package/dist/esm/storage/sqlShard/sqlShardStorage.d.ts +7 -0
  42. package/dist/esm/storage/sqlShard/sqlShardStorage.js +55 -0
  43. package/dist/esm/storage/sqlShard/sqlShardStorage.js.map +1 -0
  44. package/dist/esm/storage/sqlShard/tables.d.ts +38 -0
  45. package/dist/esm/storage/sqlShard/tables.js +2 -0
  46. package/dist/esm/storage/sqlShard/tables.js.map +1 -0
  47. package/dist/esm/storage/sqlShard/transfer.d.ts +6 -0
  48. package/dist/esm/storage/sqlShard/transfer.js +76 -0
  49. package/dist/esm/storage/sqlShard/transfer.js.map +1 -0
  50. package/dist/esm/storage/sqlShard/transfer.test.d.ts +1 -0
  51. package/dist/esm/storage/sqlShard/transfer.test.js +94 -0
  52. package/dist/esm/storage/sqlShard/transfer.test.js.map +1 -0
  53. package/package.json +3 -2
  54. package/src/ServerLibrary.test.ts +10 -0
  55. package/src/storage/index.ts +1 -0
  56. package/src/storage/sql/database.ts +32 -0
  57. package/src/storage/sql/sqlStorage.ts +6 -8
  58. package/src/storage/sql/tables.ts +4 -0
  59. package/src/storage/sqlShard/Databases.ts +80 -0
  60. package/src/storage/sqlShard/SqlBaselines.ts +140 -0
  61. package/src/storage/sqlShard/SqlFileMetadata.ts +98 -0
  62. package/src/storage/sqlShard/SqlOperations.ts +146 -0
  63. package/src/storage/sqlShard/SqlReplicas.ts +237 -0
  64. package/src/storage/sqlShard/_testData/unifiedData.ts +52 -0
  65. package/src/storage/sqlShard/database.ts +37 -0
  66. package/src/storage/sqlShard/migrations/v1.ts +53 -0
  67. package/src/storage/sqlShard/migrations.ts +3 -0
  68. package/src/storage/sqlShard/sqlShardStorage.ts +81 -0
  69. package/src/storage/sqlShard/tables.ts +45 -0
  70. package/src/storage/sqlShard/transfer.test.ts +130 -0
  71. package/src/storage/sqlShard/transfer.ts +107 -0
@@ -0,0 +1,94 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { openDatabase as openUnifiedDatabase } from '../sql/database.js';
3
+ import { randomBaselines, randomFileMetadata, randomOperations, randomReplicaInfo, } from './_testData/unifiedData.js';
4
+ import { join, dirname } from 'path';
5
+ import { transferToShards } from './transfer.js';
6
+ import { rm, mkdir } from 'fs/promises';
7
+ import { fileURLToPath } from 'url';
8
+ const testTempDir = join(dirname(fileURLToPath(import.meta.url)), '_testData', 'tmp');
9
+ describe('sql-shard storage transfer utility', () => {
10
+ const databases = new Array();
11
+ beforeAll(async () => {
12
+ try {
13
+ await rm(testTempDir, { recursive: true });
14
+ }
15
+ catch (err) { }
16
+ // create a temp dir
17
+ await mkdir(testTempDir, { recursive: true });
18
+ });
19
+ afterAll(async () => {
20
+ await Promise.all(databases.map((db) => db.destroy()));
21
+ // empty the temp dir
22
+ await rm(testTempDir, { recursive: true });
23
+ });
24
+ it('should transfer data from unified database to shards', async () => {
25
+ const unifiedFile = join(testTempDir, 'unified.sqlite');
26
+ const { db: unifiedDb, ready } = openUnifiedDatabase(unifiedFile);
27
+ databases.push(unifiedDb);
28
+ await ready;
29
+ const libraryIds = ['library-1', 'library-2', 'library-3'];
30
+ const randomData = {};
31
+ for (const libraryId of libraryIds) {
32
+ randomData[libraryId] = {
33
+ operations: randomOperations(libraryId),
34
+ baselines: randomBaselines(libraryId),
35
+ replicas: randomReplicaInfo(libraryId),
36
+ fileMetadata: randomFileMetadata(libraryId),
37
+ };
38
+ await unifiedDb
39
+ .insertInto('ReplicaInfo')
40
+ .values(randomData[libraryId].replicas)
41
+ .execute();
42
+ await unifiedDb
43
+ .insertInto('OperationHistory')
44
+ .values(randomData[libraryId].operations)
45
+ .execute();
46
+ await unifiedDb
47
+ .insertInto('DocumentBaseline')
48
+ .values(randomData[libraryId].baselines)
49
+ .execute();
50
+ await unifiedDb
51
+ .insertInto('FileMetadata')
52
+ .values(randomData[libraryId].fileMetadata)
53
+ .execute();
54
+ }
55
+ const shards = await transferToShards({
56
+ directory: testTempDir,
57
+ file: unifiedFile,
58
+ });
59
+ Object.values(shards).map((db) => databases.push(db));
60
+ expect(Object.keys(shards).sort()).toEqual(libraryIds.sort());
61
+ for (const [libraryId, db] of Object.entries(shards)) {
62
+ const operations = await db
63
+ .selectFrom('OperationHistory')
64
+ .selectAll()
65
+ .execute();
66
+ const baselines = await db
67
+ .selectFrom('DocumentBaseline')
68
+ .selectAll()
69
+ .execute();
70
+ const replicas = await db.selectFrom('ReplicaInfo').selectAll().execute();
71
+ const fileMetadata = await db
72
+ .selectFrom('FileMetadata')
73
+ .selectAll()
74
+ .execute();
75
+ expect(operations.length).toBe(randomData[libraryId].operations.length);
76
+ expect(baselines.length).toBe(randomData[libraryId].baselines.length);
77
+ expect(replicas.length).toBe(randomData[libraryId].replicas.length);
78
+ expect(fileMetadata.length).toBe(randomData[libraryId].fileMetadata.length);
79
+ for (const { oid } of randomData[libraryId].operations) {
80
+ expect(operations.find((op) => op.oid === oid)).toBeDefined();
81
+ }
82
+ for (const { oid } of randomData[libraryId].baselines) {
83
+ expect(baselines.find((baseline) => baseline.oid === oid)).toBeDefined();
84
+ }
85
+ for (const { id } of randomData[libraryId].replicas) {
86
+ expect(replicas.find((replica) => replica.id === id)).toBeDefined();
87
+ }
88
+ for (const { fileId } of randomData[libraryId].fileMetadata) {
89
+ expect(fileMetadata.find((file) => file.fileId === fileId)).toBeDefined();
90
+ }
91
+ }
92
+ });
93
+ });
94
+ //# sourceMappingURL=transfer.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transfer.test.js","sourceRoot":"","sources":["../../../../src/storage/sqlShard/transfer.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verdant-web/server",
3
- "version": "3.0.4",
3
+ "version": "3.1.0",
4
4
  "access": "public",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  "kysely-plugin-serialize": "^0.5.7",
34
34
  "minimist": "^1.2.8",
35
35
  "ws": "^8.9.0",
36
- "@verdant-web/common": "2.3.3"
36
+ "@verdant-web/common": "2.3.4"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/async-lock": "^1.3.0",
@@ -51,6 +51,7 @@
51
51
  "prepublish": "pnpm run build",
52
52
  "link": "pnpm link --global",
53
53
  "test": "vitest",
54
+ "ci:test:unit": "vitest run",
54
55
  "perf": "pnpm run build && cross-env VERDANT_LOG=false node ./perf/perf.mjs"
55
56
  }
56
57
  }
@@ -143,6 +143,16 @@ describe('ServerLibrary', () => {
143
143
  'library-1',
144
144
  {
145
145
  type: 'op-re',
146
+ baselines: [
147
+ {
148
+ oid: 'notes/1',
149
+ timestamp: `faketime-0`,
150
+ snapshot: {
151
+ title: 'My Note',
152
+ content: 'This is my note.',
153
+ },
154
+ },
155
+ ],
146
156
  operations: [
147
157
  {
148
158
  oid: 'notes/1',
@@ -1,2 +1,3 @@
1
1
  export * from './Storage.js';
2
2
  export * from './sql/sqlStorage.js';
3
+ export * from './sqlShard/sqlShardStorage.js';
@@ -0,0 +1,32 @@
1
+ import { Kysely, SqliteDialect } from 'kysely';
2
+ import { Database as DatabaseTypes } from './tables.js';
3
+ import Database from 'better-sqlite3';
4
+ import { migrateToLatest } from '@a-type/kysely';
5
+ import migrations from './migrations.js';
6
+
7
+ export function openDatabase(
8
+ file: string,
9
+ options: {
10
+ skipMigrations?: boolean;
11
+ disableWal?: boolean;
12
+ } = {},
13
+ ) {
14
+ const internalDb = new Database(file);
15
+ if (!options.disableWal) {
16
+ internalDb.pragma('journal_mode = WAL');
17
+ }
18
+ const db = new Kysely<DatabaseTypes>({
19
+ dialect: new SqliteDialect({
20
+ database: internalDb,
21
+ }),
22
+ });
23
+ let ready = Promise.resolve<void>(undefined);
24
+ if (!options.skipMigrations) {
25
+ ready = migrateToLatest(db, migrations);
26
+ }
27
+
28
+ return {
29
+ db,
30
+ ready,
31
+ };
32
+ }
@@ -8,23 +8,21 @@ import { Database as DatabaseTypes } from './tables.js';
8
8
  import { SqlFileMetadata } from './SqlFileMetadata.js';
9
9
  import { migrateToLatest } from '@a-type/kysely';
10
10
  import migrations from './migrations.js';
11
+ import { openDatabase } from './database.js';
11
12
 
12
13
  export const sqlStorage = ({
13
14
  databaseFile: dbFile,
14
15
  skipMigrations,
16
+ disableWal,
15
17
  }: {
16
18
  databaseFile: string;
17
19
  skipMigrations?: boolean;
20
+ disableWal?: boolean;
18
21
  }): StorageFactory => {
19
- const db = new Kysely<DatabaseTypes>({
20
- dialect: new SqliteDialect({
21
- database: new Database(dbFile),
22
- }),
22
+ const { db, ready } = openDatabase(dbFile, {
23
+ skipMigrations,
24
+ disableWal,
23
25
  });
24
- let ready = Promise.resolve<void>(undefined);
25
- if (!skipMigrations) {
26
- ready = migrateToLatest(db, migrations);
27
- }
28
26
  return (options) => {
29
27
  const baselines = new SqlBaselines(db, 'sqlite');
30
28
  const operations = new SqlOperations(db, 'sqlite');
@@ -36,6 +36,8 @@ export interface ReplicaInfoTable {
36
36
  ackedServerOrder: Generated<number>;
37
37
  }
38
38
 
39
+ export type ReplicaInfoRow = Selectable<ReplicaInfoTable>;
40
+
39
41
  export interface FileMetadataTable {
40
42
  libraryId: string;
41
43
  fileId: string;
@@ -43,3 +45,5 @@ export interface FileMetadataTable {
43
45
  type: string;
44
46
  pendingDeleteAt: number | null;
45
47
  }
48
+
49
+ export type FileMetadataRow = Selectable<FileMetadataTable>;
@@ -0,0 +1,80 @@
1
+ import { Kysely } from 'kysely';
2
+ import { openDatabase } from './database.js';
3
+ import { Database as DatabaseTypes } from './tables.js';
4
+ import { mkdirSync, existsSync } from 'fs';
5
+ import { assert } from '@verdant-web/common';
6
+
7
+ export class Databases {
8
+ private openPromises: Record<string, Promise<Kysely<DatabaseTypes>>> = {};
9
+ private cache: Record<string, Kysely<DatabaseTypes>> = {};
10
+ private closeTimeouts: Record<string, NodeJS.Timeout> = {};
11
+ private openedPreviously: Record<string, boolean> = {};
12
+ private directory;
13
+ private closeTimeout;
14
+ private disableWal = false;
15
+ private closed = new WeakMap<Kysely<DatabaseTypes>, boolean>();
16
+
17
+ constructor(config: {
18
+ directory: string;
19
+ closeTimeout?: number;
20
+ disableWal?: boolean;
21
+ }) {
22
+ this.directory = config.directory;
23
+ this.closeTimeout = config.closeTimeout ?? 1000 * 60 * 60;
24
+ this.disableWal = config.disableWal ?? false;
25
+ if (this.directory !== ':memory:' && !existsSync(this.directory)) {
26
+ mkdirSync(this.directory, { recursive: true });
27
+ }
28
+ }
29
+
30
+ get = async (libraryId: string): Promise<Kysely<DatabaseTypes>> => {
31
+ let db = this.cache[libraryId];
32
+ if (db) {
33
+ if (!this.closed.get(db)) {
34
+ this.enqueueClose(libraryId);
35
+ return this.cache[libraryId];
36
+ }
37
+ }
38
+ let openPromise = this.openPromises[libraryId];
39
+ if (!openPromise) {
40
+ this.openPromises[libraryId] = openDatabase(this.directory, libraryId, {
41
+ skipMigrations: this.openedPreviously[libraryId],
42
+ disableWal: this.disableWal,
43
+ });
44
+ openPromise = this.openPromises[libraryId];
45
+ }
46
+ db = await openPromise;
47
+ this.openedPreviously[libraryId] = true;
48
+ this.cache[libraryId] = db;
49
+ delete this.openPromises[libraryId];
50
+ this.enqueueClose(libraryId);
51
+ assert(db, `Database was not loaded: ${libraryId}`);
52
+ return db;
53
+ };
54
+
55
+ private enqueueClose = (libraryId: string) => {
56
+ clearTimeout(this.closeTimeouts[libraryId]);
57
+ this.closeTimeouts[libraryId] = setTimeout(
58
+ this.close,
59
+ this.closeTimeout,
60
+ libraryId,
61
+ );
62
+ };
63
+
64
+ close = (libraryId: string) => {
65
+ clearTimeout(this.closeTimeouts[libraryId]);
66
+ delete this.openPromises[libraryId];
67
+ const db = this.cache[libraryId];
68
+ if (db) {
69
+ this.closed.set(db, true);
70
+ delete this.cache[libraryId];
71
+ db.destroy();
72
+ }
73
+ };
74
+
75
+ destroy = async () => {
76
+ for (const libraryId in this.cache) {
77
+ this.close(libraryId);
78
+ }
79
+ };
80
+ }
@@ -0,0 +1,140 @@
1
+ import { Kysely } from 'kysely';
2
+ import { BaselineStorage } from '../Storage.js';
3
+ import { Database, DocumentBaselineRow } from './tables.js';
4
+ import { DocumentBaseline, Ref, applyPatch } from '@verdant-web/common';
5
+ import { HydratedDocumentBaseline, StoredOperation } from '../../types.js';
6
+ import { Databases } from './Databases.js';
7
+
8
+ export class SqlBaselines implements BaselineStorage {
9
+ constructor(
10
+ private dbs: Databases,
11
+ private dialect: 'postgres' | 'sqlite',
12
+ ) {}
13
+
14
+ get = async (
15
+ libraryId: string,
16
+ oid: string,
17
+ { tx }: { tx?: Kysely<Database> } = {},
18
+ ): Promise<HydratedDocumentBaseline | null> => {
19
+ const db = tx ?? (await this.dbs.get(libraryId));
20
+ const raw =
21
+ (await db
22
+ .selectFrom('DocumentBaseline')
23
+ .where('oid', '=', oid)
24
+ .selectAll()
25
+ .executeTakeFirst()) ?? null;
26
+ if (!raw) return null;
27
+ this.hydrate(raw);
28
+ return raw as any;
29
+ };
30
+
31
+ getAll = async (libraryId: string) => {
32
+ const db = await this.dbs.get(libraryId);
33
+ const raw = await db
34
+ .selectFrom('DocumentBaseline')
35
+ .orderBy('timestamp', 'asc')
36
+ .selectAll()
37
+ .execute();
38
+
39
+ raw.forEach(this.hydrate);
40
+
41
+ return raw as any;
42
+ };
43
+
44
+ set = async (
45
+ libraryId: string,
46
+ baseline: DocumentBaseline,
47
+ {
48
+ tx,
49
+ }: {
50
+ tx?: Kysely<Database>;
51
+ },
52
+ ) => {
53
+ const db = tx ?? (await this.dbs.get(libraryId));
54
+ if (!baseline.snapshot) {
55
+ await db
56
+ .deleteFrom('DocumentBaseline')
57
+ .where('oid', '=', baseline.oid)
58
+ .execute();
59
+ return;
60
+ }
61
+
62
+ await db
63
+ .insertInto('DocumentBaseline')
64
+ .values({
65
+ oid: baseline.oid,
66
+ snapshot: JSON.stringify(baseline.snapshot),
67
+ timestamp: baseline.timestamp,
68
+ })
69
+ .onConflict((cb) =>
70
+ cb.doUpdateSet({
71
+ snapshot: JSON.stringify(baseline.snapshot),
72
+ timestamp: baseline.timestamp,
73
+ }),
74
+ )
75
+ .execute();
76
+ };
77
+
78
+ insertAll = async (libraryId: string, baselines: DocumentBaseline[]) => {
79
+ const db = await this.dbs.get(libraryId);
80
+ await db.transaction().execute(async (tx) => {
81
+ for (const baseline of baselines) {
82
+ await this.set(libraryId, baseline, { tx });
83
+ }
84
+ });
85
+ };
86
+
87
+ private hydrate = (row: DocumentBaselineRow) => {
88
+ // avoiding extra allocation from .map by type-asserting here
89
+ row.snapshot = JSON.parse(row.snapshot);
90
+ };
91
+
92
+ applyOperations = async (
93
+ libraryId: string,
94
+ oid: string,
95
+ operations: StoredOperation[],
96
+ deletedRefs?: Ref[],
97
+ ) => {
98
+ if (operations.length === 0) return;
99
+
100
+ const db = await this.dbs.get(libraryId);
101
+
102
+ await db.transaction().execute(async (tx) => {
103
+ let baseline = await this.get(libraryId, oid, { tx });
104
+ if (!baseline) {
105
+ baseline = {
106
+ oid,
107
+ snapshot: {},
108
+ timestamp: operations[0].timestamp,
109
+ libraryId,
110
+ };
111
+ }
112
+ for (const operation of operations) {
113
+ baseline.snapshot = applyPatch(
114
+ baseline.snapshot,
115
+ operation.data,
116
+ deletedRefs,
117
+ );
118
+ }
119
+ baseline.timestamp = operations[operations.length - 1].timestamp;
120
+ await this.set(libraryId, baseline, { tx });
121
+ });
122
+ };
123
+
124
+ deleteAll = async (libraryId: string) => {
125
+ const db = await this.dbs.get(libraryId);
126
+ await db.deleteFrom('DocumentBaseline').execute();
127
+ };
128
+
129
+ getCount = async (libraryId: string) => {
130
+ const db = await this.dbs.get(libraryId);
131
+ return (
132
+ (
133
+ await db
134
+ .selectFrom('DocumentBaseline')
135
+ .select(({ fn }) => fn.countAll<number>().as('count'))
136
+ .executeTakeFirst()
137
+ )?.count ?? 0
138
+ );
139
+ };
140
+ }
@@ -0,0 +1,98 @@
1
+ import { Kysely } from 'kysely';
2
+ import { FileMetadataStorage } from '../Storage.js';
3
+ import { Database, FileMetadataRow } from './tables.js';
4
+ import { FileMetadata } from '../../types.js';
5
+ import { FileInfo } from '../../files/FileStorage.js';
6
+ import { Databases } from './Databases.js';
7
+
8
+ export class SqlFileMetadata implements FileMetadataStorage {
9
+ constructor(
10
+ private dbs: Databases,
11
+ private deleteExpirationDays: number,
12
+ private dialect: 'postgres' | 'sqlite',
13
+ ) {}
14
+
15
+ private attachLibrary = (libraryId: string, row: FileMetadataRow) => {
16
+ if (!row) return row;
17
+ (row as any).libraryId = libraryId;
18
+ return row as FileMetadata;
19
+ };
20
+
21
+ get = async (
22
+ libraryId: string,
23
+ fileId: string,
24
+ ): Promise<FileMetadata | null> => {
25
+ const db = await this.dbs.get(libraryId);
26
+ const row =
27
+ (await db
28
+ .selectFrom('FileMetadata')
29
+ .where('fileId', '=', fileId)
30
+ .selectAll()
31
+ .executeTakeFirst()) ?? null;
32
+ if (row) return this.attachLibrary(libraryId, row);
33
+ return row;
34
+ };
35
+
36
+ getAll = async (libraryId: string): Promise<FileMetadata[]> => {
37
+ const db = await this.dbs.get(libraryId);
38
+ return (await db.selectFrom('FileMetadata').selectAll().execute()).map(
39
+ this.attachLibrary.bind(this, libraryId),
40
+ );
41
+ };
42
+
43
+ deleteAll = async (libraryId: string): Promise<void> => {
44
+ const db = await this.dbs.get(libraryId);
45
+ await db.deleteFrom('FileMetadata').execute();
46
+ };
47
+
48
+ put = async (libraryId: string, fileInfo: FileInfo): Promise<void> => {
49
+ const db = await this.dbs.get(libraryId);
50
+ await db
51
+ .insertInto('FileMetadata')
52
+ .values({
53
+ fileId: fileInfo.id,
54
+ name: fileInfo.fileName,
55
+ type: fileInfo.type,
56
+ })
57
+ .onConflict((cb) =>
58
+ cb.column('fileId').doUpdateSet({
59
+ name: fileInfo.fileName,
60
+ type: fileInfo.type,
61
+ pendingDeleteAt: null,
62
+ }),
63
+ )
64
+ .execute();
65
+ };
66
+
67
+ markPendingDelete = async (
68
+ libraryId: string,
69
+ fileId: string,
70
+ ): Promise<void> => {
71
+ const db = await this.dbs.get(libraryId);
72
+ await db
73
+ .updateTable('FileMetadata')
74
+ .set({ pendingDeleteAt: Date.now() })
75
+ .where('fileId', '=', fileId)
76
+ .execute();
77
+ };
78
+
79
+ delete = async (libraryId: string, fileId: string): Promise<void> => {
80
+ const db = await this.dbs.get(libraryId);
81
+ await db.deleteFrom('FileMetadata').where('fileId', '=', fileId).execute();
82
+ };
83
+
84
+ getPendingDelete = async (libraryId: string): Promise<FileMetadata[]> => {
85
+ const db = await this.dbs.get(libraryId);
86
+ return (
87
+ await db
88
+ .selectFrom('FileMetadata')
89
+ .where(
90
+ 'pendingDeleteAt',
91
+ '<',
92
+ Date.now() - 1000 * 60 * 60 * 24 * this.deleteExpirationDays,
93
+ )
94
+ .selectAll()
95
+ .execute()
96
+ ).map(this.attachLibrary.bind(this, libraryId));
97
+ };
98
+ }
@@ -0,0 +1,146 @@
1
+ import { Operation } from '@verdant-web/common';
2
+ import { StoredOperation } from '../../types.js';
3
+ import { OperationStorage } from '../Storage.js';
4
+ import { Kysely } from 'kysely';
5
+ import { Database, OperationHistoryRow } from './tables.js';
6
+ import { Databases } from './Databases.js';
7
+
8
+ export class SqlOperations implements OperationStorage {
9
+ constructor(
10
+ private dbs: Databases,
11
+ private dialect: 'postgres' | 'sqlite',
12
+ ) {}
13
+
14
+ private hydrate = (row: OperationHistoryRow) => {
15
+ // avoiding extra allocation from .map by type-asserting here
16
+ row.data = JSON.parse(row.data);
17
+ };
18
+
19
+ getAll = async (
20
+ libraryId: string,
21
+ oid: string,
22
+ ): Promise<StoredOperation[]> => {
23
+ const db = await this.dbs.get(libraryId);
24
+ const raw = await db
25
+ .selectFrom('OperationHistory')
26
+ .where('oid', '=', oid)
27
+ .orderBy('timestamp', 'asc')
28
+ .selectAll()
29
+ .execute();
30
+
31
+ raw.forEach(this.hydrate);
32
+ return raw as unknown as StoredOperation[];
33
+ };
34
+
35
+ getBeforeServerOrder = async (
36
+ libraryId: string,
37
+ beforeServerOrder: number,
38
+ ): Promise<StoredOperation[]> => {
39
+ const db = await this.dbs.get(libraryId);
40
+ const raw = await db
41
+ .selectFrom('OperationHistory')
42
+ .where('serverOrder', '<', beforeServerOrder)
43
+ .orderBy('timestamp', 'asc')
44
+ .selectAll()
45
+ .execute();
46
+
47
+ raw.forEach(this.hydrate);
48
+ return raw as unknown as StoredOperation[];
49
+ };
50
+
51
+ getAfterServerOrder = async (
52
+ libraryId: string,
53
+ afterServerOrder: number,
54
+ ): Promise<StoredOperation[]> => {
55
+ const db = await this.dbs.get(libraryId);
56
+ const raw = await db
57
+ .selectFrom('OperationHistory')
58
+ .where('serverOrder', '>', afterServerOrder)
59
+ .orderBy('timestamp', 'asc')
60
+ .selectAll()
61
+ .execute();
62
+
63
+ raw.forEach(this.hydrate);
64
+ return raw as unknown as StoredOperation[];
65
+ };
66
+
67
+ getLatestServerOrder = async (libraryId: string): Promise<number> => {
68
+ const db = await this.dbs.get(libraryId);
69
+ return (
70
+ (
71
+ await db
72
+ .selectFrom('OperationHistory')
73
+ .orderBy('serverOrder', 'desc')
74
+ .select('serverOrder')
75
+ .limit(1)
76
+ .executeTakeFirst()
77
+ )?.serverOrder ?? 0
78
+ );
79
+ };
80
+
81
+ getCount = async (libraryId: string): Promise<number> => {
82
+ const db = await this.dbs.get(libraryId);
83
+ return (
84
+ (
85
+ await db
86
+ .selectFrom('OperationHistory')
87
+ .select(({ fn }) => fn.countAll<number>().as('count'))
88
+ .executeTakeFirst()
89
+ )?.count ?? 0
90
+ );
91
+ };
92
+
93
+ insertAll = async (
94
+ libraryId: string,
95
+ replicaId: string,
96
+ operations: Operation[],
97
+ ): Promise<void> => {
98
+ const db = await this.dbs.get(libraryId);
99
+ // inserts all operations and updates server order
100
+ // FIXME: this whole thing is kinda sus
101
+ await db.transaction().execute(async (tx): Promise<void> => {
102
+ let orderResult = await tx
103
+ .selectFrom('OperationHistory')
104
+ .select(({ fn, val }) =>
105
+ fn.coalesce(fn.max<number>('serverOrder'), val(0)).as('serverOrder'),
106
+ )
107
+ .executeTakeFirst();
108
+ let currentServerOrder = orderResult?.serverOrder ?? 0;
109
+ for (const item of operations) {
110
+ await tx
111
+ .insertInto('OperationHistory')
112
+ .values({
113
+ oid: item.oid,
114
+ data: JSON.stringify(item.data),
115
+ timestamp: item.timestamp,
116
+ replicaId,
117
+ serverOrder: ++currentServerOrder,
118
+ })
119
+ .onConflict((cb) =>
120
+ cb.columns(['replicaId', 'oid', 'timestamp']).doNothing(),
121
+ )
122
+ .execute();
123
+ }
124
+ });
125
+ };
126
+
127
+ deleteAll = async (libraryId: string): Promise<void> => {
128
+ const db = await this.dbs.get(libraryId);
129
+ await db.deleteFrom('OperationHistory').execute();
130
+ };
131
+ delete = async (
132
+ libraryId: string,
133
+ operations: Operation[],
134
+ ): Promise<void> => {
135
+ const db = await this.dbs.get(libraryId);
136
+ await db.transaction().execute(async (tx) => {
137
+ for (const item of operations) {
138
+ await tx
139
+ .deleteFrom('OperationHistory')
140
+ .where('oid', '=', item.oid)
141
+ .where('timestamp', '=', item.timestamp)
142
+ .execute();
143
+ }
144
+ });
145
+ };
146
+ }