@verdant-web/server 3.0.5 → 3.1.1

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 (70) hide show
  1. package/dist/esm/ServerLibrary.test.js +10 -0
  2. package/dist/esm/ServerLibrary.test.js.map +1 -1
  3. package/dist/esm/storage/index.d.ts +1 -0
  4. package/dist/esm/storage/index.js +1 -0
  5. package/dist/esm/storage/index.js.map +1 -1
  6. package/dist/esm/storage/sql/database.d.ts +9 -0
  7. package/dist/esm/storage/sql/database.js +24 -0
  8. package/dist/esm/storage/sql/database.js.map +1 -0
  9. package/dist/esm/storage/sql/sqlStorage.d.ts +2 -1
  10. package/dist/esm/storage/sql/sqlStorage.js +5 -13
  11. package/dist/esm/storage/sql/sqlStorage.js.map +1 -1
  12. package/dist/esm/storage/sql/tables.d.ts +2 -0
  13. package/dist/esm/storage/sqlShard/Databases.d.ts +21 -0
  14. package/dist/esm/storage/sqlShard/Databases.js +64 -0
  15. package/dist/esm/storage/sqlShard/Databases.js.map +1 -0
  16. package/dist/esm/storage/sqlShard/SqlBaselines.d.ts +23 -0
  17. package/dist/esm/storage/sqlShard/SqlBaselines.js +98 -0
  18. package/dist/esm/storage/sqlShard/SqlBaselines.js.map +1 -0
  19. package/dist/esm/storage/sqlShard/SqlFileMetadata.d.ts +18 -0
  20. package/dist/esm/storage/sqlShard/SqlFileMetadata.js +70 -0
  21. package/dist/esm/storage/sqlShard/SqlFileMetadata.js.map +1 -0
  22. package/dist/esm/storage/sqlShard/SqlOperations.d.ts +18 -0
  23. package/dist/esm/storage/sqlShard/SqlOperations.js +104 -0
  24. package/dist/esm/storage/sqlShard/SqlOperations.js.map +1 -0
  25. package/dist/esm/storage/sqlShard/SqlReplicas.d.ts +32 -0
  26. package/dist/esm/storage/sqlShard/SqlReplicas.js +164 -0
  27. package/dist/esm/storage/sqlShard/SqlReplicas.js.map +1 -0
  28. package/dist/esm/storage/sqlShard/_testData/unifiedData.d.ts +30 -0
  29. package/dist/esm/storage/sqlShard/_testData/unifiedData.js +42 -0
  30. package/dist/esm/storage/sqlShard/_testData/unifiedData.js.map +1 -0
  31. package/dist/esm/storage/sqlShard/database.d.ts +6 -0
  32. package/dist/esm/storage/sqlShard/database.js +28 -0
  33. package/dist/esm/storage/sqlShard/database.js.map +1 -0
  34. package/dist/esm/storage/sqlShard/migrations/v1.d.ts +3 -0
  35. package/dist/esm/storage/sqlShard/migrations/v1.js +48 -0
  36. package/dist/esm/storage/sqlShard/migrations/v1.js.map +1 -0
  37. package/dist/esm/storage/sqlShard/migrations.d.ts +5 -0
  38. package/dist/esm/storage/sqlShard/migrations.js +3 -0
  39. package/dist/esm/storage/sqlShard/migrations.js.map +1 -0
  40. package/dist/esm/storage/sqlShard/sqlShardStorage.d.ts +7 -0
  41. package/dist/esm/storage/sqlShard/sqlShardStorage.js +55 -0
  42. package/dist/esm/storage/sqlShard/sqlShardStorage.js.map +1 -0
  43. package/dist/esm/storage/sqlShard/tables.d.ts +38 -0
  44. package/dist/esm/storage/sqlShard/tables.js +2 -0
  45. package/dist/esm/storage/sqlShard/tables.js.map +1 -0
  46. package/dist/esm/storage/sqlShard/transfer.d.ts +7 -0
  47. package/dist/esm/storage/sqlShard/transfer.js +90 -0
  48. package/dist/esm/storage/sqlShard/transfer.js.map +1 -0
  49. package/dist/esm/storage/sqlShard/transfer.test.d.ts +1 -0
  50. package/dist/esm/storage/sqlShard/transfer.test.js +109 -0
  51. package/dist/esm/storage/sqlShard/transfer.test.js.map +1 -0
  52. package/package.json +2 -1
  53. package/src/ServerLibrary.test.ts +10 -0
  54. package/src/storage/index.ts +1 -0
  55. package/src/storage/sql/database.ts +32 -0
  56. package/src/storage/sql/sqlStorage.ts +6 -8
  57. package/src/storage/sql/tables.ts +4 -0
  58. package/src/storage/sqlShard/Databases.ts +80 -0
  59. package/src/storage/sqlShard/SqlBaselines.ts +140 -0
  60. package/src/storage/sqlShard/SqlFileMetadata.ts +98 -0
  61. package/src/storage/sqlShard/SqlOperations.ts +146 -0
  62. package/src/storage/sqlShard/SqlReplicas.ts +237 -0
  63. package/src/storage/sqlShard/_testData/unifiedData.ts +45 -0
  64. package/src/storage/sqlShard/database.ts +37 -0
  65. package/src/storage/sqlShard/migrations/v1.ts +53 -0
  66. package/src/storage/sqlShard/migrations.ts +3 -0
  67. package/src/storage/sqlShard/sqlShardStorage.ts +81 -0
  68. package/src/storage/sqlShard/tables.ts +45 -0
  69. package/src/storage/sqlShard/transfer.test.ts +149 -0
  70. package/src/storage/sqlShard/transfer.ts +123 -0
@@ -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
+ }
@@ -0,0 +1,237 @@
1
+ import { StoredReplicaInfo } from '../../types.js';
2
+ import { ReplicaStorage } from '../Storage.js';
3
+ import { sql, type Kysely } from 'kysely';
4
+ import { Database, ReplicaInfoRow } from './tables.js';
5
+ import { ReplicaType, VerdantError } from '@verdant-web/common';
6
+ import { Databases } from './Databases.js';
7
+
8
+ export class SqlReplicas implements ReplicaStorage {
9
+ constructor(
10
+ private dbs: Databases,
11
+ private readonly replicaTruancyMinutes: number,
12
+ private readonly dialect: 'sqlite' | 'postgres',
13
+ ) {}
14
+
15
+ get truantCutoff(): number {
16
+ return Date.now() - this.replicaTruancyMinutes * 60 * 1000;
17
+ }
18
+
19
+ private attachLibraryId = (libraryId: string, row: ReplicaInfoRow) => {
20
+ (row as any).libraryId = libraryId;
21
+ return row as StoredReplicaInfo;
22
+ };
23
+
24
+ get = async (
25
+ libraryId: string,
26
+ replicaId: string,
27
+ ): Promise<StoredReplicaInfo | null> => {
28
+ const db = await this.dbs.get(libraryId);
29
+ const row =
30
+ (await db
31
+ .selectFrom('ReplicaInfo')
32
+ .where('id', '=', replicaId)
33
+ .selectAll()
34
+ .executeTakeFirst()) ?? null;
35
+ if (row) return this.attachLibraryId(libraryId, row);
36
+ return row;
37
+ };
38
+ getOrCreate = async (
39
+ libraryId: string,
40
+ replicaId: string,
41
+ info: { userId: string; type: ReplicaType },
42
+ ): Promise<{
43
+ status: 'new' | 'existing' | 'truant';
44
+ replicaInfo: StoredReplicaInfo;
45
+ }> => {
46
+ const existing = await this.get(libraryId, replicaId);
47
+ if (!existing) {
48
+ const db = await this.dbs.get(libraryId);
49
+ const created = await db
50
+ .insertInto('ReplicaInfo')
51
+ .values({
52
+ id: replicaId,
53
+ clientId: info.userId,
54
+ type: info.type,
55
+ ackedServerOrder: 0,
56
+ })
57
+ .returningAll()
58
+ .executeTakeFirst();
59
+ if (!created) {
60
+ throw new VerdantError(
61
+ VerdantError.Code.Unexpected,
62
+ undefined,
63
+ 'Failed to create replica',
64
+ );
65
+ }
66
+ return {
67
+ status: 'new',
68
+ replicaInfo: this.attachLibraryId(libraryId, created),
69
+ };
70
+ }
71
+
72
+ if (existing.type !== info.type) {
73
+ const db = await this.dbs.get(libraryId);
74
+ // type should be updated if a new token changes it
75
+ await db
76
+ .updateTable('ReplicaInfo')
77
+ .set({ type: info.type })
78
+ .where('id', '=', replicaId)
79
+ .execute();
80
+ }
81
+
82
+ if (existing.clientId !== info.userId) {
83
+ // replicas cannot change hands - this is a security issue
84
+ throw new VerdantError(
85
+ VerdantError.Code.Forbidden,
86
+ undefined,
87
+ 'Another user is already using this replica ID',
88
+ );
89
+ }
90
+
91
+ if (
92
+ existing.lastSeenWallClockTime !== null &&
93
+ existing.lastSeenWallClockTime < this.truantCutoff
94
+ ) {
95
+ return { status: 'truant', replicaInfo: existing };
96
+ }
97
+
98
+ return { status: 'existing', replicaInfo: existing };
99
+ };
100
+
101
+ getAll = async (
102
+ libraryId: string,
103
+ options?: { omitTruant: boolean } | undefined,
104
+ ): Promise<StoredReplicaInfo[]> => {
105
+ const db = await this.dbs.get(libraryId);
106
+ let builder = db.selectFrom('ReplicaInfo');
107
+
108
+ if (options?.omitTruant) {
109
+ builder = builder.where('lastSeenWallClockTime', '>', this.truantCutoff);
110
+ }
111
+
112
+ return (await builder.selectAll().execute()).map(
113
+ this.attachLibraryId.bind(this, libraryId),
114
+ );
115
+ };
116
+
117
+ updateLastSeen = async (
118
+ libraryId: string,
119
+ replicaId: string,
120
+ ): Promise<void> => {
121
+ const clockTime = Date.now();
122
+ const db = await this.dbs.get(libraryId);
123
+ await db
124
+ .updateTable('ReplicaInfo')
125
+ .set({ lastSeenWallClockTime: clockTime })
126
+ .where('id', '=', replicaId)
127
+ .execute();
128
+ };
129
+
130
+ updateAckedServerOrder = async (
131
+ libraryId: string,
132
+ replicaId: string,
133
+ serverOrder: number,
134
+ ): Promise<void> => {
135
+ const max = this.dialect === 'postgres' ? 'GREATEST' : 'MAX';
136
+ const db = await this.dbs.get(libraryId);
137
+ await db
138
+ .updateTable('ReplicaInfo')
139
+ .set(
140
+ 'ackedServerOrder',
141
+ ({ val }) =>
142
+ sql<number>`${sql.raw(max)}(ackedServerOrder, ${val(serverOrder)})`,
143
+ )
144
+ .where('id', '=', replicaId)
145
+ .execute();
146
+ };
147
+
148
+ updateAcknowledgedLogicalTime = async (
149
+ libraryId: string,
150
+ replicaId: string,
151
+ timestamp: string,
152
+ ): Promise<void> => {
153
+ const db = await this.dbs.get(libraryId);
154
+ await db
155
+ .updateTable('ReplicaInfo')
156
+ .set({ ackedLogicalTime: timestamp })
157
+ .where('id', '=', replicaId)
158
+ .execute();
159
+ };
160
+
161
+ getEarliestAckedServerOrder = async (libraryId: string): Promise<number> => {
162
+ const db = await this.dbs.get(libraryId);
163
+ // gets earliest acked server order of all non-truant replicas.
164
+ const res = await db
165
+ .selectFrom('ReplicaInfo')
166
+ .where('lastSeenWallClockTime', '>', this.truantCutoff)
167
+ .orderBy('ackedServerOrder', 'asc')
168
+ .select('ackedServerOrder')
169
+ .executeTakeFirst();
170
+ return res?.ackedServerOrder ?? 0;
171
+ };
172
+
173
+ acknowledgeOperation = async (
174
+ libraryId: string,
175
+ replicaId: string,
176
+ timestamp: string,
177
+ ): Promise<void> => {
178
+ if (!timestamp) return;
179
+ const db = await this.dbs.get(libraryId);
180
+ // when acking an operation, we also set the replica's server order
181
+ // to that operation's server order, if it's greater.
182
+ const max = this.dialect === 'postgres' ? 'GREATEST' : 'MAX';
183
+ await db
184
+ .updateTable('ReplicaInfo')
185
+ .set({ ackedLogicalTime: timestamp })
186
+ .set(
187
+ 'ackedServerOrder',
188
+ ({ val }) => sql<number>`${sql.raw(max)}(
189
+ ackedServerOrder,
190
+ COALESCE(
191
+ (
192
+ SELECT serverOrder FROM OperationHistory
193
+ WHERE timestamp = ${val(timestamp)}
194
+ ),
195
+ 0
196
+ )
197
+ )`,
198
+ )
199
+ .where('id', '=', replicaId)
200
+ .execute();
201
+ };
202
+
203
+ getGlobalAck = async (
204
+ libraryId: string,
205
+ onlineReplicaIds?: string[] | undefined,
206
+ ): Promise<string | null> => {
207
+ const nonTruant = await this.getAll(libraryId, { omitTruant: true });
208
+ if (nonTruant.length === 0) return null;
209
+ const globalAckEligible = nonTruant.filter(
210
+ (replica) => replica.type < 2 || onlineReplicaIds?.includes(replica.id),
211
+ );
212
+
213
+ return globalAckEligible.reduce(
214
+ (acc, replica) => {
215
+ if (!replica.ackedLogicalTime) return acc;
216
+ if (acc === null) return replica.ackedLogicalTime;
217
+ return acc < replica.ackedLogicalTime ? acc : replica.ackedLogicalTime;
218
+ },
219
+ null as string | null,
220
+ );
221
+ };
222
+ delete = async (libraryId: string, replicaId: string): Promise<void> => {
223
+ const db = await this.dbs.get(libraryId);
224
+ await db.deleteFrom('ReplicaInfo').where('id', '=', replicaId).execute();
225
+ };
226
+ deleteAll = async (libraryId: string): Promise<void> => {
227
+ const db = await this.dbs.get(libraryId);
228
+ await db.deleteFrom('ReplicaInfo').execute();
229
+ };
230
+ deleteAllForUser = async (
231
+ libraryId: string,
232
+ userId: string,
233
+ ): Promise<void> => {
234
+ const db = await this.dbs.get(libraryId);
235
+ await db.deleteFrom('ReplicaInfo').where('clientId', '=', userId).execute();
236
+ };
237
+ }
@@ -0,0 +1,45 @@
1
+ function random() {
2
+ return Math.random().toString(36).substring(7);
3
+ }
4
+
5
+ export function randomOperations(libraryId: string, count = 10) {
6
+ return Array.from({ length: count }, (_, i) => ({
7
+ oid: `oid-${i}-${random()}`,
8
+ timestamp: `timestamp-${i}-${random()}`,
9
+ data: `data-${i}-${random()}`,
10
+ serverOrder: i,
11
+ replicaId: `replicaId-${i}`,
12
+ libraryId,
13
+ }));
14
+ }
15
+
16
+ export function randomBaselines(libraryId: string, count = 10) {
17
+ return Array.from({ length: count }, (_, i) => ({
18
+ oid: `oid-${i}-${random()}`,
19
+ snapshot: `snapshot-${i}-${random()}`,
20
+ timestamp: `timestamp-${i}-${random()}`,
21
+ libraryId,
22
+ }));
23
+ }
24
+
25
+ export function randomFileMetadata(libraryId: string, count = 10) {
26
+ return Array.from({ length: count }, (_, i) => ({
27
+ libraryId,
28
+ fileId: `fileId-${i}-${random()}`,
29
+ name: `name-${i}-${random()}`,
30
+ type: `type-${i}`,
31
+ pendingDeleteAt: i,
32
+ }));
33
+ }
34
+
35
+ export function randomReplicaInfo(libraryId: string, count = 10) {
36
+ return Array.from({ length: count }, (_, i) => ({
37
+ libraryId,
38
+ id: `replicaId-${i}`,
39
+ clientId: `clientId-${i}-${random()}`,
40
+ lastSeenWallClockTime: i,
41
+ ackedLogicalTime: `ackedLogicalTime-${i}-${random()}`,
42
+ type: i,
43
+ ackedServerOrder: i,
44
+ }));
45
+ }
@@ -0,0 +1,37 @@
1
+ import { Kysely, SqliteDialect } from 'kysely';
2
+ import { join } from 'path';
3
+ import { Database as DatabaseTypes } from './tables.js';
4
+ import Database from 'better-sqlite3';
5
+ import { migrateToLatest } from '@a-type/kysely';
6
+ import migrations from './migrations.js';
7
+
8
+ export async function openDatabase(
9
+ directory: string,
10
+ libraryId: string,
11
+ options: {
12
+ skipMigrations?: boolean;
13
+ disableWal?: boolean;
14
+ } = {},
15
+ ) {
16
+ const label = `openDatabase ${libraryId}`;
17
+ console.time(label);
18
+ const filePath =
19
+ directory === ':memory:'
20
+ ? ':memory:'
21
+ : join(directory, `${libraryId}.sqlite`);
22
+ const internalDb = new Database(filePath);
23
+ if (!options.disableWal) {
24
+ internalDb.pragma('journal_mode = WAL');
25
+ }
26
+ const db = new Kysely<DatabaseTypes>({
27
+ dialect: new SqliteDialect({
28
+ database: internalDb,
29
+ }),
30
+ });
31
+ // only migrate on first open
32
+ if (!options.skipMigrations) {
33
+ await migrateToLatest(db, migrations);
34
+ }
35
+ console.timeEnd(label);
36
+ return db;
37
+ }
@@ -0,0 +1,53 @@
1
+ import { Kysely } from 'kysely';
2
+
3
+ export async function up(db: Kysely<any>) {
4
+ await db.schema
5
+ .createTable('DocumentBaseline')
6
+ .ifNotExists()
7
+ .addColumn('oid', 'text', (cb) => cb.primaryKey())
8
+ .addColumn('snapshot', 'text')
9
+ .addColumn('timestamp', 'text', (cb) => cb.notNull())
10
+ .execute();
11
+
12
+ await db.schema
13
+ .createTable('OperationHistory')
14
+ .ifNotExists()
15
+ .addColumn('oid', 'text', (cb) => cb.notNull())
16
+ .addColumn('timestamp', 'text', (cb) => cb.notNull())
17
+ .addColumn('data', 'text', (cb) => cb.notNull())
18
+ .addColumn('serverOrder', 'integer', (cb) => cb.notNull().defaultTo(0))
19
+ .addColumn('replicaId', 'text', (cb) => cb.notNull())
20
+ .addPrimaryKeyConstraint('OperationHistory_primaryKey', [
21
+ 'replicaId',
22
+ 'oid',
23
+ 'timestamp',
24
+ ])
25
+ .execute();
26
+
27
+ await db.schema
28
+ .createTable('ReplicaInfo')
29
+ .ifNotExists()
30
+ .addColumn('id', 'text', (cb) => cb.primaryKey())
31
+ .addColumn('clientId', 'text', (cb) => cb.notNull())
32
+ .addColumn('lastSeenWallClockTime', 'integer')
33
+ .addColumn('ackedLogicalTime', 'text')
34
+ .addColumn('type', 'integer', (cb) => cb.notNull().defaultTo(0))
35
+ .addColumn('ackedServerOrder', 'integer', (cb) => cb.notNull().defaultTo(0))
36
+ .execute();
37
+
38
+ await db.schema
39
+ .createTable('FileMetadata')
40
+ .ifNotExists()
41
+ .addColumn('fileId', 'text', (cb) => cb.primaryKey())
42
+ .addColumn('name', 'text', (cb) => cb.notNull())
43
+ .addColumn('type', 'text', (cb) => cb.notNull())
44
+ .addColumn('pendingDeleteAt', 'integer')
45
+ .execute();
46
+ }
47
+
48
+ export async function down(db: Kysely<any>) {
49
+ await db.schema.dropTable('DocumentBaseline').execute();
50
+ await db.schema.dropTable('OperationHistory').execute();
51
+ await db.schema.dropTable('ReplicaInfo').execute();
52
+ await db.schema.dropTable('FileMetadata').execute();
53
+ }
@@ -0,0 +1,3 @@
1
+ import * as v1 from './migrations/v1.js';
2
+
3
+ export default { v1 };
@@ -0,0 +1,81 @@
1
+ import Database from 'better-sqlite3';
2
+ import { Kysely, SqliteDialect } from 'kysely';
3
+ import { StorageFactory } from '../Storage.js';
4
+ import { SqlBaselines } from './SqlBaselines.js';
5
+ import { SqlOperations } from './SqlOperations.js';
6
+ import { SqlReplicas } from './SqlReplicas.js';
7
+ import { Database as DatabaseTypes } from './tables.js';
8
+ import { SqlFileMetadata } from './SqlFileMetadata.js';
9
+ import { migrateToLatest } from '@a-type/kysely';
10
+ import migrations from './migrations.js';
11
+ import { Databases } from './Databases.js';
12
+ import { transferToShards } from './transfer.js';
13
+ import { existsSync, mkdirSync, readdirSync } from 'fs';
14
+
15
+ export const sqlShardStorage = ({
16
+ databasesDirectory,
17
+ transferFromUnifiedDatabaseFile,
18
+ disableWal,
19
+ closeTimeout,
20
+ }: {
21
+ databasesDirectory: string;
22
+ transferFromUnifiedDatabaseFile?: string;
23
+ disableWal?: boolean;
24
+ closeTimeout?: number;
25
+ }): StorageFactory => {
26
+ let ready = Promise.resolve<void>(undefined);
27
+ if (!existsSync(databasesDirectory)) {
28
+ mkdirSync(databasesDirectory);
29
+ console.info(`Created databases directory: ${databasesDirectory}`);
30
+ }
31
+ if (transferFromUnifiedDatabaseFile) {
32
+ // check if directory is empty
33
+ const files =
34
+ databasesDirectory === ':memory:' ? [] : readdirSync(databasesDirectory);
35
+ if (files.length > 0) {
36
+ console.error(
37
+ `Cannot transfer from unified database to non-empty directory: ${databasesDirectory}. This might mean the transfer has already happened and you're free to turn off the transferFromUnifiedDatabaseFile option.`,
38
+ );
39
+ } else {
40
+ ready = transferToShards({
41
+ file: transferFromUnifiedDatabaseFile,
42
+ directory: databasesDirectory,
43
+ }).then();
44
+ }
45
+ }
46
+ const dbs = new Databases({
47
+ directory: databasesDirectory,
48
+ disableWal,
49
+ closeTimeout,
50
+ });
51
+ return (options) => {
52
+ const baselines = new SqlBaselines(dbs, 'sqlite');
53
+ const operations = new SqlOperations(dbs, 'sqlite');
54
+ const replicas = new SqlReplicas(
55
+ dbs,
56
+ options.replicaTruancyMinutes,
57
+ 'sqlite',
58
+ );
59
+ const fileMetadata = new SqlFileMetadata(
60
+ dbs,
61
+ options.fileDeleteExpirationDays,
62
+ 'sqlite',
63
+ );
64
+ const close = async () => {
65
+ storage.open = false;
66
+ await dbs.destroy();
67
+ };
68
+ const storage = {
69
+ baselines,
70
+ operations,
71
+ replicas,
72
+ fileMetadata,
73
+ close,
74
+ open: false,
75
+ ready: ready.then(() => {
76
+ storage.open = true;
77
+ }),
78
+ };
79
+ return storage;
80
+ };
81
+ };
@@ -0,0 +1,45 @@
1
+ import { ReplicaType } from '@verdant-web/common';
2
+ import { Generated, Selectable } from 'kysely';
3
+
4
+ export interface Database {
5
+ OperationHistory: OperationHistoryTable;
6
+ DocumentBaseline: DocumentBaselineTable;
7
+ ReplicaInfo: ReplicaInfoTable;
8
+ FileMetadata: FileMetadataTable;
9
+ }
10
+
11
+ export interface OperationHistoryTable {
12
+ oid: string;
13
+ timestamp: string;
14
+ data: string;
15
+ serverOrder: number;
16
+ replicaId: string;
17
+ }
18
+ export type OperationHistoryRow = Selectable<OperationHistoryTable>;
19
+
20
+ export interface DocumentBaselineTable {
21
+ oid: string;
22
+ snapshot: string;
23
+ timestamp: string;
24
+ }
25
+ export type DocumentBaselineRow = Selectable<DocumentBaselineTable>;
26
+
27
+ export interface ReplicaInfoTable {
28
+ id: string;
29
+ clientId: string;
30
+ lastSeenWallClockTime: number | null;
31
+ ackedLogicalTime: string | null;
32
+ type: ReplicaType;
33
+ ackedServerOrder: Generated<number>;
34
+ }
35
+
36
+ export type ReplicaInfoRow = Selectable<ReplicaInfoTable>;
37
+
38
+ export interface FileMetadataTable {
39
+ fileId: string;
40
+ name: string;
41
+ type: string;
42
+ pendingDeleteAt: number | null;
43
+ }
44
+
45
+ export type FileMetadataRow = Selectable<FileMetadataTable>;