@verdant-web/server 3.0.5 → 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.
- package/dist/esm/ServerLibrary.test.js +10 -0
- package/dist/esm/ServerLibrary.test.js.map +1 -1
- package/dist/esm/storage/index.d.ts +1 -0
- package/dist/esm/storage/index.js +1 -0
- package/dist/esm/storage/index.js.map +1 -1
- package/dist/esm/storage/sql/database.d.ts +9 -0
- package/dist/esm/storage/sql/database.js +24 -0
- package/dist/esm/storage/sql/database.js.map +1 -0
- package/dist/esm/storage/sql/sqlStorage.d.ts +2 -1
- package/dist/esm/storage/sql/sqlStorage.js +5 -13
- package/dist/esm/storage/sql/sqlStorage.js.map +1 -1
- package/dist/esm/storage/sql/tables.d.ts +2 -0
- package/dist/esm/storage/sqlShard/Databases.d.ts +21 -0
- package/dist/esm/storage/sqlShard/Databases.js +64 -0
- package/dist/esm/storage/sqlShard/Databases.js.map +1 -0
- package/dist/esm/storage/sqlShard/SqlBaselines.d.ts +23 -0
- package/dist/esm/storage/sqlShard/SqlBaselines.js +98 -0
- package/dist/esm/storage/sqlShard/SqlBaselines.js.map +1 -0
- package/dist/esm/storage/sqlShard/SqlFileMetadata.d.ts +18 -0
- package/dist/esm/storage/sqlShard/SqlFileMetadata.js +70 -0
- package/dist/esm/storage/sqlShard/SqlFileMetadata.js.map +1 -0
- package/dist/esm/storage/sqlShard/SqlOperations.d.ts +18 -0
- package/dist/esm/storage/sqlShard/SqlOperations.js +104 -0
- package/dist/esm/storage/sqlShard/SqlOperations.js.map +1 -0
- package/dist/esm/storage/sqlShard/SqlReplicas.d.ts +32 -0
- package/dist/esm/storage/sqlShard/SqlReplicas.js +164 -0
- package/dist/esm/storage/sqlShard/SqlReplicas.js.map +1 -0
- package/dist/esm/storage/sqlShard/_testData/unifiedData.d.ts +30 -0
- package/dist/esm/storage/sqlShard/_testData/unifiedData.js +42 -0
- package/dist/esm/storage/sqlShard/_testData/unifiedData.js.map +1 -0
- package/dist/esm/storage/sqlShard/database.d.ts +6 -0
- package/dist/esm/storage/sqlShard/database.js +28 -0
- package/dist/esm/storage/sqlShard/database.js.map +1 -0
- package/dist/esm/storage/sqlShard/migrations/v1.d.ts +3 -0
- package/dist/esm/storage/sqlShard/migrations/v1.js +48 -0
- package/dist/esm/storage/sqlShard/migrations/v1.js.map +1 -0
- package/dist/esm/storage/sqlShard/migrations.d.ts +5 -0
- package/dist/esm/storage/sqlShard/migrations.js +3 -0
- package/dist/esm/storage/sqlShard/migrations.js.map +1 -0
- package/dist/esm/storage/sqlShard/sqlShardStorage.d.ts +7 -0
- package/dist/esm/storage/sqlShard/sqlShardStorage.js +55 -0
- package/dist/esm/storage/sqlShard/sqlShardStorage.js.map +1 -0
- package/dist/esm/storage/sqlShard/tables.d.ts +38 -0
- package/dist/esm/storage/sqlShard/tables.js +2 -0
- package/dist/esm/storage/sqlShard/tables.js.map +1 -0
- package/dist/esm/storage/sqlShard/transfer.d.ts +6 -0
- package/dist/esm/storage/sqlShard/transfer.js +76 -0
- package/dist/esm/storage/sqlShard/transfer.js.map +1 -0
- package/dist/esm/storage/sqlShard/transfer.test.d.ts +1 -0
- package/dist/esm/storage/sqlShard/transfer.test.js +94 -0
- package/dist/esm/storage/sqlShard/transfer.test.js.map +1 -0
- package/package.json +2 -1
- package/src/ServerLibrary.test.ts +10 -0
- package/src/storage/index.ts +1 -0
- package/src/storage/sql/database.ts +32 -0
- package/src/storage/sql/sqlStorage.ts +6 -8
- package/src/storage/sql/tables.ts +4 -0
- package/src/storage/sqlShard/Databases.ts +80 -0
- package/src/storage/sqlShard/SqlBaselines.ts +140 -0
- package/src/storage/sqlShard/SqlFileMetadata.ts +98 -0
- package/src/storage/sqlShard/SqlOperations.ts +146 -0
- package/src/storage/sqlShard/SqlReplicas.ts +237 -0
- package/src/storage/sqlShard/_testData/unifiedData.ts +52 -0
- package/src/storage/sqlShard/database.ts +37 -0
- package/src/storage/sqlShard/migrations/v1.ts +53 -0
- package/src/storage/sqlShard/migrations.ts +3 -0
- package/src/storage/sqlShard/sqlShardStorage.ts +81 -0
- package/src/storage/sqlShard/tables.ts +45 -0
- package/src/storage/sqlShard/transfer.test.ts +130 -0
- 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,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
|
+
});
|