@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.
- 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 +7 -0
- package/dist/esm/storage/sqlShard/transfer.js +90 -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 +109 -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 +45 -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 +149 -0
- 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,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>;
|