@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,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,52 @@
1
+ import {
2
+ OperationHistoryRow,
3
+ DocumentBaselineRow,
4
+ FileMetadataRow,
5
+ ReplicaInfoRow,
6
+ } from '../../sql/tables.js';
7
+
8
+ function random() {
9
+ return Math.random().toString(36).substring(7);
10
+ }
11
+
12
+ export function randomOperations(libraryId: string) {
13
+ return Array.from({ length: 10 }, (_, i) => ({
14
+ oid: `oid-${i}-${random()}`,
15
+ timestamp: `timestamp-${i}-${random()}`,
16
+ data: `data-${i}-${random()}`,
17
+ serverOrder: i,
18
+ replicaId: `replicaId-${i}`,
19
+ libraryId,
20
+ }));
21
+ }
22
+
23
+ export function randomBaselines(libraryId: string) {
24
+ return Array.from({ length: 10 }, (_, i) => ({
25
+ oid: `oid-${i}-${random()}`,
26
+ snapshot: `snapshot-${i}-${random()}`,
27
+ timestamp: `timestamp-${i}-${random()}`,
28
+ libraryId,
29
+ }));
30
+ }
31
+
32
+ export function randomFileMetadata(libraryId: string) {
33
+ return Array.from({ length: 10 }, (_, i) => ({
34
+ libraryId,
35
+ fileId: `fileId-${i}-${random()}`,
36
+ name: `name-${i}-${random()}`,
37
+ type: `type-${i}`,
38
+ pendingDeleteAt: i,
39
+ }));
40
+ }
41
+
42
+ export function randomReplicaInfo(libraryId: string) {
43
+ return Array.from({ length: 10 }, (_, i) => ({
44
+ libraryId,
45
+ id: `replicaId-${i}`,
46
+ clientId: `clientId-${i}-${random()}`,
47
+ lastSeenWallClockTime: i,
48
+ ackedLogicalTime: `ackedLogicalTime-${i}-${random()}`,
49
+ type: i,
50
+ ackedServerOrder: i,
51
+ }));
52
+ }
@@ -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>;
@@ -0,0 +1,130 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { openDatabase as openUnifiedDatabase } from '../sql/database.js';
3
+ import {
4
+ randomBaselines,
5
+ randomFileMetadata,
6
+ randomOperations,
7
+ randomReplicaInfo,
8
+ } from './_testData/unifiedData.js';
9
+ import { join, dirname } from 'path';
10
+ import { transferToShards } from './transfer.js';
11
+ import { rm, mkdir } from 'fs/promises';
12
+ import { fileURLToPath } from 'url';
13
+ import {
14
+ DocumentBaselineRow,
15
+ FileMetadataRow,
16
+ OperationHistoryRow,
17
+ ReplicaInfoRow,
18
+ } from '../sql/tables.js';
19
+ import { Kysely } from 'kysely';
20
+
21
+ const testTempDir = join(
22
+ dirname(fileURLToPath(import.meta.url)),
23
+ '_testData',
24
+ 'tmp',
25
+ );
26
+
27
+ describe('sql-shard storage transfer utility', () => {
28
+ const databases = new Array<Kysely<any>>();
29
+ beforeAll(async () => {
30
+ try {
31
+ await rm(testTempDir, { recursive: true });
32
+ } catch (err) {}
33
+ // create a temp dir
34
+ await mkdir(testTempDir, { recursive: true });
35
+ });
36
+ afterAll(async () => {
37
+ await Promise.all(databases.map((db) => db.destroy()));
38
+ // empty the temp dir
39
+ await rm(testTempDir, { recursive: true });
40
+ });
41
+
42
+ it('should transfer data from unified database to shards', async () => {
43
+ const unifiedFile = join(testTempDir, 'unified.sqlite');
44
+ const { db: unifiedDb, ready } = openUnifiedDatabase(unifiedFile);
45
+ databases.push(unifiedDb);
46
+ await ready;
47
+ const libraryIds = ['library-1', 'library-2', 'library-3'];
48
+ const randomData = {} as Record<
49
+ string,
50
+ {
51
+ operations: OperationHistoryRow[];
52
+ baselines: DocumentBaselineRow[];
53
+ replicas: ReplicaInfoRow[];
54
+ fileMetadata: FileMetadataRow[];
55
+ }
56
+ >;
57
+ for (const libraryId of libraryIds) {
58
+ randomData[libraryId] = {
59
+ operations: randomOperations(libraryId),
60
+ baselines: randomBaselines(libraryId),
61
+ replicas: randomReplicaInfo(libraryId),
62
+ fileMetadata: randomFileMetadata(libraryId),
63
+ };
64
+ await unifiedDb
65
+ .insertInto('ReplicaInfo')
66
+ .values(randomData[libraryId].replicas)
67
+ .execute();
68
+ await unifiedDb
69
+ .insertInto('OperationHistory')
70
+ .values(randomData[libraryId].operations)
71
+ .execute();
72
+ await unifiedDb
73
+ .insertInto('DocumentBaseline')
74
+ .values(randomData[libraryId].baselines)
75
+ .execute();
76
+ await unifiedDb
77
+ .insertInto('FileMetadata')
78
+ .values(randomData[libraryId].fileMetadata)
79
+ .execute();
80
+ }
81
+
82
+ const shards = await transferToShards({
83
+ directory: testTempDir,
84
+ file: unifiedFile,
85
+ });
86
+ Object.values(shards).map((db) => databases.push(db));
87
+
88
+ expect(Object.keys(shards).sort()).toEqual(libraryIds.sort());
89
+
90
+ for (const [libraryId, db] of Object.entries(shards)) {
91
+ const operations = await db
92
+ .selectFrom('OperationHistory')
93
+ .selectAll()
94
+ .execute();
95
+ const baselines = await db
96
+ .selectFrom('DocumentBaseline')
97
+ .selectAll()
98
+ .execute();
99
+ const replicas = await db.selectFrom('ReplicaInfo').selectAll().execute();
100
+ const fileMetadata = await db
101
+ .selectFrom('FileMetadata')
102
+ .selectAll()
103
+ .execute();
104
+
105
+ expect(operations.length).toBe(randomData[libraryId].operations.length);
106
+ expect(baselines.length).toBe(randomData[libraryId].baselines.length);
107
+ expect(replicas.length).toBe(randomData[libraryId].replicas.length);
108
+ expect(fileMetadata.length).toBe(
109
+ randomData[libraryId].fileMetadata.length,
110
+ );
111
+
112
+ for (const { oid } of randomData[libraryId].operations) {
113
+ expect(operations.find((op) => op.oid === oid)).toBeDefined();
114
+ }
115
+ for (const { oid } of randomData[libraryId].baselines) {
116
+ expect(
117
+ baselines.find((baseline) => baseline.oid === oid),
118
+ ).toBeDefined();
119
+ }
120
+ for (const { id } of randomData[libraryId].replicas) {
121
+ expect(replicas.find((replica) => replica.id === id)).toBeDefined();
122
+ }
123
+ for (const { fileId } of randomData[libraryId].fileMetadata) {
124
+ expect(
125
+ fileMetadata.find((file) => file.fileId === fileId),
126
+ ).toBeDefined();
127
+ }
128
+ }
129
+ });
130
+ });