@verdant-web/server 2.1.3 → 3.0.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/ClientConnection.d.ts +1 -0
- package/dist/esm/ClientConnection.js.map +1 -1
- package/dist/esm/Presence.js.map +1 -1
- package/dist/esm/Profiles.js.map +1 -1
- package/dist/esm/ReplicaKeepaliveTimers.js.map +1 -1
- package/dist/esm/Server.d.ts +15 -11
- package/dist/esm/Server.js +29 -30
- package/dist/esm/Server.js.map +1 -1
- package/dist/esm/ServerLibrary.d.ts +10 -17
- package/dist/esm/ServerLibrary.js +151 -127
- package/dist/esm/ServerLibrary.js.map +1 -1
- package/dist/esm/ServerLibrary.test.js +369 -0
- package/dist/esm/ServerLibrary.test.js.map +1 -0
- package/dist/esm/TokenVerifier.js.map +1 -1
- package/dist/esm/files/FileStorage.js.map +1 -1
- package/dist/esm/storage/Storage.d.ts +67 -0
- package/dist/esm/storage/Storage.js +2 -0
- package/dist/esm/storage/Storage.js.map +1 -0
- package/dist/esm/storage/index.d.ts +2 -0
- package/dist/esm/storage/index.js +3 -0
- package/dist/esm/storage/index.js.map +1 -0
- package/dist/esm/storage/sql/SqlBaselines.d.ts +22 -0
- package/dist/esm/storage/sql/SqlBaselines.js +101 -0
- package/dist/esm/storage/sql/SqlBaselines.js.map +1 -0
- package/dist/esm/storage/sql/SqlFileMetadata.d.ts +18 -0
- package/dist/esm/storage/sql/SqlFileMetadata.js +69 -0
- package/dist/esm/storage/sql/SqlFileMetadata.js.map +1 -0
- package/dist/esm/storage/sql/SqlOperations.d.ts +19 -0
- package/dist/esm/storage/sql/SqlOperations.js +104 -0
- package/dist/esm/storage/sql/SqlOperations.js.map +1 -0
- package/dist/esm/storage/sql/SqlReplicas.d.ts +32 -0
- package/dist/esm/storage/sql/SqlReplicas.js +163 -0
- package/dist/esm/storage/sql/SqlReplicas.js.map +1 -0
- package/dist/esm/storage/sql/migrations/v1.d.ts +3 -0
- package/dist/esm/storage/sql/migrations/v1.js +59 -0
- package/dist/esm/storage/sql/migrations/v1.js.map +1 -0
- package/dist/esm/storage/sql/migrations.d.ts +5 -0
- package/dist/esm/storage/sql/migrations.js +3 -0
- package/dist/esm/storage/sql/migrations.js.map +1 -0
- package/dist/esm/storage/sql/sqlStorage.d.ts +5 -0
- package/dist/esm/storage/sql/sqlStorage.js +43 -0
- package/dist/esm/storage/sql/sqlStorage.js.map +1 -0
- package/dist/esm/storage/sql/tables.d.ts +40 -0
- package/dist/esm/storage/sql/tables.js +2 -0
- package/dist/esm/storage/sql/tables.js.map +1 -0
- package/dist/esm/types.d.ts +14 -5
- package/package.json +20 -14
- package/src/Server.ts +37 -43
- package/src/ServerLibrary.test.ts +447 -0
- package/src/ServerLibrary.ts +239 -163
- package/src/storage/Storage.ts +118 -0
- package/src/storage/index.ts +2 -0
- package/src/storage/sql/SqlBaselines.ts +141 -0
- package/src/storage/sql/SqlFileMetadata.ts +94 -0
- package/src/storage/sql/SqlOperations.ts +149 -0
- package/src/storage/sql/SqlReplicas.ts +234 -0
- package/src/storage/sql/migrations/v1.ts +64 -0
- package/src/storage/sql/migrations.ts +3 -0
- package/src/storage/sql/sqlStorage.ts +59 -0
- package/src/storage/sql/tables.ts +45 -0
- package/src/types.ts +15 -4
- package/bin/server.mjs +0 -115
- package/dist/cjs/Baselines.d.ts +0 -16
- package/dist/cjs/Baselines.js +0 -128
- package/dist/cjs/Baselines.js.map +0 -1
- package/dist/cjs/ClientConnection.d.ts +0 -20
- package/dist/cjs/ClientConnection.js +0 -139
- package/dist/cjs/ClientConnection.js.map +0 -1
- package/dist/cjs/MessageSender.d.ts +0 -5
- package/dist/cjs/MessageSender.js +0 -3
- package/dist/cjs/MessageSender.js.map +0 -1
- package/dist/cjs/OperationHistory.d.ts +0 -36
- package/dist/cjs/OperationHistory.js +0 -165
- package/dist/cjs/OperationHistory.js.map +0 -1
- package/dist/cjs/Presence.d.ts +0 -15
- package/dist/cjs/Presence.js +0 -60
- package/dist/cjs/Presence.js.map +0 -1
- package/dist/cjs/Profiles.d.ts +0 -10
- package/dist/cjs/Profiles.js +0 -20
- package/dist/cjs/Profiles.js.map +0 -1
- package/dist/cjs/ReplicaKeepaliveTimers.d.ts +0 -17
- package/dist/cjs/ReplicaKeepaliveTimers.js +0 -31
- package/dist/cjs/ReplicaKeepaliveTimers.js.map +0 -1
- package/dist/cjs/Replicas.d.ts +0 -25
- package/dist/cjs/Replicas.js +0 -196
- package/dist/cjs/Replicas.js.map +0 -1
- package/dist/cjs/Server.d.ts +0 -167
- package/dist/cjs/Server.js +0 -552
- package/dist/cjs/Server.js.map +0 -1
- package/dist/cjs/ServerLibrary.d.ts +0 -65
- package/dist/cjs/ServerLibrary.js +0 -505
- package/dist/cjs/ServerLibrary.js.map +0 -1
- package/dist/cjs/TokenProvider.d.ts +0 -19
- package/dist/cjs/TokenProvider.js +0 -27
- package/dist/cjs/TokenProvider.js.map +0 -1
- package/dist/cjs/TokenVerifier.d.ts +0 -16
- package/dist/cjs/TokenVerifier.js +0 -40
- package/dist/cjs/TokenVerifier.js.map +0 -1
- package/dist/cjs/files/FileMetadata.d.ts +0 -28
- package/dist/cjs/files/FileMetadata.js +0 -69
- package/dist/cjs/files/FileMetadata.js.map +0 -1
- package/dist/cjs/files/FileStorage.d.ts +0 -35
- package/dist/cjs/files/FileStorage.js +0 -72
- package/dist/cjs/files/FileStorage.js.map +0 -1
- package/dist/cjs/index.d.ts +0 -10
- package/dist/cjs/index.js +0 -12
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/migrations/v0.d.ts +0 -1
- package/dist/cjs/migrations/v0.js +0 -30
- package/dist/cjs/migrations/v0.js.map +0 -1
- package/dist/cjs/migrations/v1.d.ts +0 -1
- package/dist/cjs/migrations/v1.js +0 -32
- package/dist/cjs/migrations/v1.js.map +0 -1
- package/dist/cjs/migrations/v2.d.ts +0 -1
- package/dist/cjs/migrations/v2.js +0 -29
- package/dist/cjs/migrations/v2.js.map +0 -1
- package/dist/cjs/migrations/v3.d.ts +0 -1
- package/dist/cjs/migrations/v3.js +0 -15
- package/dist/cjs/migrations/v3.js.map +0 -1
- package/dist/cjs/migrations/v4.d.ts +0 -1
- package/dist/cjs/migrations/v4.js +0 -9
- package/dist/cjs/migrations/v4.js.map +0 -1
- package/dist/cjs/migrations/v5.d.ts +0 -2
- package/dist/cjs/migrations/v5.js +0 -37
- package/dist/cjs/migrations/v5.js.map +0 -1
- package/dist/cjs/migrations/v6.d.ts +0 -1
- package/dist/cjs/migrations/v6.js +0 -8
- package/dist/cjs/migrations/v6.js.map +0 -1
- package/dist/cjs/migrations/v7.d.ts +0 -1
- package/dist/cjs/migrations/v7.js +0 -10
- package/dist/cjs/migrations/v7.js.map +0 -1
- package/dist/cjs/migrations.d.ts +0 -6
- package/dist/cjs/migrations.js +0 -72
- package/dist/cjs/migrations.js.map +0 -1
- package/dist/cjs/migrations.test.js +0 -121
- package/dist/cjs/migrations.test.js.map +0 -1
- package/dist/cjs/types.d.ts +0 -38
- package/dist/cjs/types.js +0 -3
- package/dist/cjs/types.js.map +0 -1
- package/dist/esm/Baselines.d.ts +0 -16
- package/dist/esm/Baselines.js +0 -124
- package/dist/esm/Baselines.js.map +0 -1
- package/dist/esm/OperationHistory.d.ts +0 -36
- package/dist/esm/OperationHistory.js +0 -161
- package/dist/esm/OperationHistory.js.map +0 -1
- package/dist/esm/Replicas.d.ts +0 -25
- package/dist/esm/Replicas.js +0 -192
- package/dist/esm/Replicas.js.map +0 -1
- package/dist/esm/files/FileMetadata.d.ts +0 -28
- package/dist/esm/files/FileMetadata.js +0 -65
- package/dist/esm/files/FileMetadata.js.map +0 -1
- package/dist/esm/migrations/v0.d.ts +0 -1
- package/dist/esm/migrations/v0.js +0 -27
- package/dist/esm/migrations/v0.js.map +0 -1
- package/dist/esm/migrations/v1.d.ts +0 -1
- package/dist/esm/migrations/v1.js +0 -29
- package/dist/esm/migrations/v1.js.map +0 -1
- package/dist/esm/migrations/v2.d.ts +0 -1
- package/dist/esm/migrations/v2.js +0 -26
- package/dist/esm/migrations/v2.js.map +0 -1
- package/dist/esm/migrations/v3.d.ts +0 -1
- package/dist/esm/migrations/v3.js +0 -12
- package/dist/esm/migrations/v3.js.map +0 -1
- package/dist/esm/migrations/v4.d.ts +0 -1
- package/dist/esm/migrations/v4.js +0 -6
- package/dist/esm/migrations/v4.js.map +0 -1
- package/dist/esm/migrations/v5.d.ts +0 -2
- package/dist/esm/migrations/v5.js +0 -33
- package/dist/esm/migrations/v5.js.map +0 -1
- package/dist/esm/migrations/v6.d.ts +0 -1
- package/dist/esm/migrations/v6.js +0 -5
- package/dist/esm/migrations/v6.js.map +0 -1
- package/dist/esm/migrations/v7.d.ts +0 -1
- package/dist/esm/migrations/v7.js +0 -7
- package/dist/esm/migrations/v7.js.map +0 -1
- package/dist/esm/migrations.d.ts +0 -6
- package/dist/esm/migrations.js +0 -45
- package/dist/esm/migrations.js.map +0 -1
- package/dist/esm/migrations.test.d.ts +0 -1
- package/dist/esm/migrations.test.js +0 -116
- package/dist/esm/migrations.test.js.map +0 -1
- package/dist/tsconfig-cjs.tsbuildinfo +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/Baselines.ts +0 -198
- package/src/OperationHistory.ts +0 -232
- package/src/Replicas.ts +0 -274
- package/src/files/FileMetadata.ts +0 -112
- package/src/migrations/v0.ts +0 -26
- package/src/migrations/v1.ts +0 -28
- package/src/migrations/v2.ts +0 -26
- package/src/migrations/v3.ts +0 -12
- package/src/migrations/v4.ts +0 -6
- package/src/migrations/v5.ts +0 -49
- package/src/migrations/v6.ts +0 -4
- package/src/migrations/v7.ts +0 -6
- package/src/migrations.test.ts +0 -133
- package/src/migrations.ts +0 -61
- /package/dist/{cjs/migrations.test.d.ts → esm/ServerLibrary.test.d.ts} +0 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { StoredReplicaInfo } from '../../types.js';
|
|
2
|
+
import { ReplicaStorage } from '../Storage.js';
|
|
3
|
+
import { sql, type Kysely } from 'kysely';
|
|
4
|
+
import { Database } from './tables.js';
|
|
5
|
+
import { ReplicaType, VerdantError } from '@verdant-web/common';
|
|
6
|
+
|
|
7
|
+
export class SqlReplicas implements ReplicaStorage {
|
|
8
|
+
constructor(
|
|
9
|
+
private db: Kysely<Database>,
|
|
10
|
+
private readonly replicaTruancyMinutes: number,
|
|
11
|
+
private readonly dialect: 'sqlite' | 'postgres',
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
get truantCutoff(): number {
|
|
15
|
+
return Date.now() - this.replicaTruancyMinutes * 60 * 1000;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get = async (
|
|
19
|
+
libraryId: string,
|
|
20
|
+
replicaId: string,
|
|
21
|
+
): Promise<StoredReplicaInfo | null> => {
|
|
22
|
+
return (
|
|
23
|
+
(await this.db
|
|
24
|
+
.selectFrom('ReplicaInfo')
|
|
25
|
+
.where('libraryId', '=', libraryId)
|
|
26
|
+
.where('id', '=', replicaId)
|
|
27
|
+
.selectAll()
|
|
28
|
+
.executeTakeFirst()) ?? null
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
getOrCreate = async (
|
|
32
|
+
libraryId: string,
|
|
33
|
+
replicaId: string,
|
|
34
|
+
info: { userId: string; type: ReplicaType },
|
|
35
|
+
): Promise<{
|
|
36
|
+
status: 'new' | 'existing' | 'truant';
|
|
37
|
+
replicaInfo: StoredReplicaInfo;
|
|
38
|
+
}> => {
|
|
39
|
+
const existing = await this.get(libraryId, replicaId);
|
|
40
|
+
if (!existing) {
|
|
41
|
+
const created = await this.db
|
|
42
|
+
.insertInto('ReplicaInfo')
|
|
43
|
+
.values({
|
|
44
|
+
id: replicaId,
|
|
45
|
+
clientId: info.userId,
|
|
46
|
+
libraryId,
|
|
47
|
+
type: info.type,
|
|
48
|
+
ackedServerOrder: 0,
|
|
49
|
+
})
|
|
50
|
+
.returningAll()
|
|
51
|
+
.executeTakeFirst();
|
|
52
|
+
if (!created) {
|
|
53
|
+
throw new VerdantError(
|
|
54
|
+
VerdantError.Code.Unexpected,
|
|
55
|
+
undefined,
|
|
56
|
+
'Failed to create replica',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return { status: 'new', replicaInfo: created };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (existing.type !== info.type) {
|
|
63
|
+
// type should be updated if a new token changes it
|
|
64
|
+
await this.db
|
|
65
|
+
.updateTable('ReplicaInfo')
|
|
66
|
+
.set({ type: info.type })
|
|
67
|
+
.where('libraryId', '=', libraryId)
|
|
68
|
+
.where('id', '=', replicaId)
|
|
69
|
+
.execute();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (existing.clientId !== info.userId) {
|
|
73
|
+
// replicas cannot change hands - this is a security issue
|
|
74
|
+
throw new VerdantError(
|
|
75
|
+
VerdantError.Code.Forbidden,
|
|
76
|
+
undefined,
|
|
77
|
+
'Another user is already using this replica ID',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
existing.lastSeenWallClockTime !== null &&
|
|
83
|
+
existing.lastSeenWallClockTime < this.truantCutoff
|
|
84
|
+
) {
|
|
85
|
+
return { status: 'truant', replicaInfo: existing };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { status: 'existing', replicaInfo: existing };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
getAll = async (
|
|
92
|
+
libraryId: string,
|
|
93
|
+
options?: { omitTruant: boolean } | undefined,
|
|
94
|
+
): Promise<StoredReplicaInfo[]> => {
|
|
95
|
+
let builder = this.db
|
|
96
|
+
.selectFrom('ReplicaInfo')
|
|
97
|
+
.where('libraryId', '=', libraryId);
|
|
98
|
+
|
|
99
|
+
if (options?.omitTruant) {
|
|
100
|
+
builder = builder.where('lastSeenWallClockTime', '>', this.truantCutoff);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return builder.selectAll().execute();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
updateLastSeen = async (
|
|
107
|
+
libraryId: string,
|
|
108
|
+
replicaId: string,
|
|
109
|
+
): Promise<void> => {
|
|
110
|
+
const clockTime = Date.now();
|
|
111
|
+
await this.db
|
|
112
|
+
.updateTable('ReplicaInfo')
|
|
113
|
+
.set({ lastSeenWallClockTime: clockTime })
|
|
114
|
+
.where('libraryId', '=', libraryId)
|
|
115
|
+
.where('id', '=', replicaId)
|
|
116
|
+
.execute();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
updateAckedServerOrder = async (
|
|
120
|
+
libraryId: string,
|
|
121
|
+
replicaId: string,
|
|
122
|
+
serverOrder: number,
|
|
123
|
+
): Promise<void> => {
|
|
124
|
+
const max = this.dialect === 'postgres' ? 'GREATEST' : 'MAX';
|
|
125
|
+
await this.db
|
|
126
|
+
.updateTable('ReplicaInfo')
|
|
127
|
+
.set(
|
|
128
|
+
'ackedServerOrder',
|
|
129
|
+
({ val }) =>
|
|
130
|
+
sql<number>`${sql.raw(max)}(ackedServerOrder, ${val(serverOrder)})`,
|
|
131
|
+
)
|
|
132
|
+
.where('libraryId', '=', libraryId)
|
|
133
|
+
.where('id', '=', replicaId)
|
|
134
|
+
.execute();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
updateAcknowledgedLogicalTime = async (
|
|
138
|
+
libraryId: string,
|
|
139
|
+
replicaId: string,
|
|
140
|
+
timestamp: string,
|
|
141
|
+
): Promise<void> => {
|
|
142
|
+
await this.db
|
|
143
|
+
.updateTable('ReplicaInfo')
|
|
144
|
+
.set({ ackedLogicalTime: timestamp })
|
|
145
|
+
.where('libraryId', '=', libraryId)
|
|
146
|
+
.where('id', '=', replicaId)
|
|
147
|
+
.execute();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
getEarliestAckedServerOrder = async (libraryId: string): Promise<number> => {
|
|
151
|
+
// gets earliest acked server order of all non-truant replicas.
|
|
152
|
+
const res = await this.db
|
|
153
|
+
.selectFrom('ReplicaInfo')
|
|
154
|
+
.where('libraryId', '=', libraryId)
|
|
155
|
+
.where('lastSeenWallClockTime', '>', this.truantCutoff)
|
|
156
|
+
.orderBy('ackedServerOrder', 'asc')
|
|
157
|
+
.select('ackedServerOrder')
|
|
158
|
+
.executeTakeFirst();
|
|
159
|
+
return res?.ackedServerOrder ?? 0;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
acknowledgeOperation = async (
|
|
163
|
+
libraryId: string,
|
|
164
|
+
replicaId: string,
|
|
165
|
+
timestamp: string,
|
|
166
|
+
): Promise<void> => {
|
|
167
|
+
if (!timestamp) return;
|
|
168
|
+
// when acking an operation, we also set the replica's server order
|
|
169
|
+
// to that operation's server order, if it's greater.
|
|
170
|
+
const max = this.dialect === 'postgres' ? 'GREATEST' : 'MAX';
|
|
171
|
+
await this.db
|
|
172
|
+
.updateTable('ReplicaInfo')
|
|
173
|
+
.set({ ackedLogicalTime: timestamp })
|
|
174
|
+
.set(
|
|
175
|
+
'ackedServerOrder',
|
|
176
|
+
({ val }) => sql<number>`${sql.raw(max)}(
|
|
177
|
+
ackedServerOrder,
|
|
178
|
+
COALESCE(
|
|
179
|
+
(
|
|
180
|
+
SELECT serverOrder FROM OperationHistory
|
|
181
|
+
WHERE libraryId = ${val(libraryId)} AND timestamp = ${val(timestamp)}
|
|
182
|
+
),
|
|
183
|
+
0
|
|
184
|
+
)
|
|
185
|
+
)`,
|
|
186
|
+
)
|
|
187
|
+
.where('libraryId', '=', libraryId)
|
|
188
|
+
.where('id', '=', replicaId)
|
|
189
|
+
.execute();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
getGlobalAck = async (
|
|
193
|
+
libraryId: string,
|
|
194
|
+
onlineReplicaIds?: string[] | undefined,
|
|
195
|
+
): Promise<string | null> => {
|
|
196
|
+
const nonTruant = await this.getAll(libraryId, { omitTruant: true });
|
|
197
|
+
if (nonTruant.length === 0) return null;
|
|
198
|
+
const globalAckEligible = nonTruant.filter(
|
|
199
|
+
(replica) => replica.type < 2 || onlineReplicaIds?.includes(replica.id),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
return globalAckEligible.reduce(
|
|
203
|
+
(acc, replica) => {
|
|
204
|
+
if (!replica.ackedLogicalTime) return acc;
|
|
205
|
+
if (acc === null) return replica.ackedLogicalTime;
|
|
206
|
+
return acc < replica.ackedLogicalTime ? acc : replica.ackedLogicalTime;
|
|
207
|
+
},
|
|
208
|
+
null as string | null,
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
delete = async (libraryId: string, replicaId: string): Promise<void> => {
|
|
212
|
+
await this.db
|
|
213
|
+
.deleteFrom('ReplicaInfo')
|
|
214
|
+
.where('libraryId', '=', libraryId)
|
|
215
|
+
.where('id', '=', replicaId)
|
|
216
|
+
.execute();
|
|
217
|
+
};
|
|
218
|
+
deleteAll = async (libraryId: string): Promise<void> => {
|
|
219
|
+
await this.db
|
|
220
|
+
.deleteFrom('ReplicaInfo')
|
|
221
|
+
.where('libraryId', '=', libraryId)
|
|
222
|
+
.execute();
|
|
223
|
+
};
|
|
224
|
+
deleteAllForUser = async (
|
|
225
|
+
libraryId: string,
|
|
226
|
+
userId: string,
|
|
227
|
+
): Promise<void> => {
|
|
228
|
+
await this.db
|
|
229
|
+
.deleteFrom('ReplicaInfo')
|
|
230
|
+
.where('libraryId', '=', libraryId)
|
|
231
|
+
.where('clientId', '=', userId)
|
|
232
|
+
.execute();
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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')
|
|
8
|
+
.addColumn('snapshot', 'text')
|
|
9
|
+
.addColumn('timestamp', 'text', (cb) => cb.notNull())
|
|
10
|
+
.addColumn('libraryId', 'text', (cb) => cb.notNull())
|
|
11
|
+
.addPrimaryKeyConstraint('DocumentBaseline_primaryKey', [
|
|
12
|
+
'libraryId',
|
|
13
|
+
'oid',
|
|
14
|
+
])
|
|
15
|
+
.execute();
|
|
16
|
+
|
|
17
|
+
await db.schema
|
|
18
|
+
.createTable('OperationHistory')
|
|
19
|
+
.ifNotExists()
|
|
20
|
+
.addColumn('oid', 'text', (cb) => cb.notNull())
|
|
21
|
+
.addColumn('timestamp', 'text', (cb) => cb.notNull())
|
|
22
|
+
.addColumn('data', 'text', (cb) => cb.notNull())
|
|
23
|
+
.addColumn('serverOrder', 'integer', (cb) => cb.notNull().defaultTo(0))
|
|
24
|
+
.addColumn('replicaId', 'text', (cb) => cb.notNull())
|
|
25
|
+
.addColumn('libraryId', 'text', (cb) => cb.notNull())
|
|
26
|
+
.addPrimaryKeyConstraint('OperationHistory_primaryKey', [
|
|
27
|
+
'libraryId',
|
|
28
|
+
'replicaId',
|
|
29
|
+
'oid',
|
|
30
|
+
'timestamp',
|
|
31
|
+
])
|
|
32
|
+
.execute();
|
|
33
|
+
|
|
34
|
+
await db.schema
|
|
35
|
+
.createTable('ReplicaInfo')
|
|
36
|
+
.ifNotExists()
|
|
37
|
+
.addColumn('id', 'text')
|
|
38
|
+
.addColumn('libraryId', 'text', (cb) => cb.notNull())
|
|
39
|
+
.addColumn('clientId', 'text', (cb) => cb.notNull())
|
|
40
|
+
.addColumn('lastSeenWallClockTime', 'integer')
|
|
41
|
+
.addColumn('ackedLogicalTime', 'text')
|
|
42
|
+
.addColumn('type', 'integer', (cb) => cb.notNull().defaultTo(0))
|
|
43
|
+
.addColumn('ackedServerOrder', 'integer', (cb) => cb.notNull().defaultTo(0))
|
|
44
|
+
.addPrimaryKeyConstraint('ReplicaInfo_primaryKey', ['libraryId', 'id'])
|
|
45
|
+
.execute();
|
|
46
|
+
|
|
47
|
+
await db.schema
|
|
48
|
+
.createTable('FileMetadata')
|
|
49
|
+
.ifNotExists()
|
|
50
|
+
.addColumn('libraryId', 'text', (cb) => cb.notNull())
|
|
51
|
+
.addColumn('fileId', 'text', (cb) => cb.notNull())
|
|
52
|
+
.addColumn('name', 'text', (cb) => cb.notNull())
|
|
53
|
+
.addColumn('type', 'text', (cb) => cb.notNull())
|
|
54
|
+
.addColumn('pendingDeleteAt', 'integer')
|
|
55
|
+
.addPrimaryKeyConstraint('FileMetadata_primaryKey', ['libraryId', 'fileId'])
|
|
56
|
+
.execute();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function down(db: Kysely<any>) {
|
|
60
|
+
await db.schema.dropTable('DocumentBaseline').execute();
|
|
61
|
+
await db.schema.dropTable('OperationHistory').execute();
|
|
62
|
+
await db.schema.dropTable('ReplicaInfo').execute();
|
|
63
|
+
await db.schema.dropTable('FileMetadata').execute();
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
|
|
12
|
+
export const sqlStorage = ({
|
|
13
|
+
databaseFile: dbFile,
|
|
14
|
+
skipMigrations,
|
|
15
|
+
}: {
|
|
16
|
+
databaseFile: string;
|
|
17
|
+
skipMigrations?: boolean;
|
|
18
|
+
}): StorageFactory => {
|
|
19
|
+
const db = new Kysely<DatabaseTypes>({
|
|
20
|
+
dialect: new SqliteDialect({
|
|
21
|
+
database: new Database(dbFile),
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
let ready = Promise.resolve<void>(undefined);
|
|
25
|
+
if (!skipMigrations) {
|
|
26
|
+
ready = migrateToLatest(db, migrations);
|
|
27
|
+
}
|
|
28
|
+
return (options) => {
|
|
29
|
+
const baselines = new SqlBaselines(db, 'sqlite');
|
|
30
|
+
const operations = new SqlOperations(db, 'sqlite');
|
|
31
|
+
const replicas = new SqlReplicas(
|
|
32
|
+
db,
|
|
33
|
+
options.replicaTruancyMinutes,
|
|
34
|
+
'sqlite',
|
|
35
|
+
);
|
|
36
|
+
const fileMetadata = new SqlFileMetadata(
|
|
37
|
+
db,
|
|
38
|
+
options.fileDeleteExpirationDays,
|
|
39
|
+
'sqlite',
|
|
40
|
+
);
|
|
41
|
+
const close = async () => {
|
|
42
|
+
storage.open = false;
|
|
43
|
+
await db.destroy();
|
|
44
|
+
};
|
|
45
|
+
const storage = {
|
|
46
|
+
baselines,
|
|
47
|
+
operations,
|
|
48
|
+
replicas,
|
|
49
|
+
fileMetadata,
|
|
50
|
+
close,
|
|
51
|
+
open: false,
|
|
52
|
+
ready,
|
|
53
|
+
};
|
|
54
|
+
ready.then(() => {
|
|
55
|
+
storage.open = true;
|
|
56
|
+
});
|
|
57
|
+
return storage;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -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
|
+
libraryId: string;
|
|
18
|
+
}
|
|
19
|
+
export type OperationHistoryRow = Selectable<OperationHistoryTable>;
|
|
20
|
+
|
|
21
|
+
export interface DocumentBaselineTable {
|
|
22
|
+
oid: string;
|
|
23
|
+
snapshot: string;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
libraryId: string;
|
|
26
|
+
}
|
|
27
|
+
export type DocumentBaselineRow = Selectable<DocumentBaselineTable>;
|
|
28
|
+
|
|
29
|
+
export interface ReplicaInfoTable {
|
|
30
|
+
id: string;
|
|
31
|
+
libraryId: string;
|
|
32
|
+
clientId: string;
|
|
33
|
+
lastSeenWallClockTime: number | null;
|
|
34
|
+
ackedLogicalTime: string | null;
|
|
35
|
+
type: ReplicaType;
|
|
36
|
+
ackedServerOrder: Generated<number>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FileMetadataTable {
|
|
40
|
+
libraryId: string;
|
|
41
|
+
fileId: string;
|
|
42
|
+
name: string;
|
|
43
|
+
type: string;
|
|
44
|
+
pendingDeleteAt: number | null;
|
|
45
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -5,19 +5,22 @@ import {
|
|
|
5
5
|
ReplicaType,
|
|
6
6
|
} from '@verdant-web/common';
|
|
7
7
|
|
|
8
|
-
export interface
|
|
8
|
+
export interface StoredOperation extends Operation {
|
|
9
9
|
serverOrder: number;
|
|
10
10
|
replicaId: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export interface
|
|
13
|
+
export interface StoredDocumentBaseline
|
|
14
14
|
extends Omit<DocumentBaseline<any>, 'snapshot'> {
|
|
15
15
|
snapshot: string;
|
|
16
16
|
libraryId: string;
|
|
17
|
-
serverOrder: number;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
export interface
|
|
19
|
+
export interface HydratedDocumentBaseline extends DocumentBaseline<any> {
|
|
20
|
+
libraryId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StoredReplicaInfo extends ReplicaInfo {
|
|
21
24
|
libraryId: string;
|
|
22
25
|
// the authenticated client ID authorized
|
|
23
26
|
// to write to this replica
|
|
@@ -43,6 +46,14 @@ export type LibraryInfo<Profile = unknown> = {
|
|
|
43
46
|
globalAck: string | null;
|
|
44
47
|
};
|
|
45
48
|
|
|
49
|
+
export interface FileMetadata {
|
|
50
|
+
libraryId: string;
|
|
51
|
+
fileId: string;
|
|
52
|
+
name: string;
|
|
53
|
+
type: string;
|
|
54
|
+
pendingDeleteAt: number | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
// patch File
|
|
47
58
|
declare global {
|
|
48
59
|
interface File {
|
package/bin/server.mjs
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { createServer } from 'http';
|
|
3
|
-
import { createReadStream } from 'fs';
|
|
4
|
-
import { parse, URLSearchParams } from 'url';
|
|
5
|
-
import {
|
|
6
|
-
LocalFileStorage,
|
|
7
|
-
Server,
|
|
8
|
-
TokenProvider,
|
|
9
|
-
ReplicaType,
|
|
10
|
-
} from '../dist/esm/index.js';
|
|
11
|
-
import minimist from 'minimist';
|
|
12
|
-
|
|
13
|
-
const argv = minimist(process.argv.slice(1));
|
|
14
|
-
|
|
15
|
-
const PORT = argv.port || argv.p || process.env.VERDANT_PORT || 3242;
|
|
16
|
-
const SECRET =
|
|
17
|
-
argv.secret || process.env.VERDANT_SECRET || 'notsecretnotsecretnotsecret';
|
|
18
|
-
|
|
19
|
-
const tokenProvider = new TokenProvider({ secret: SECRET });
|
|
20
|
-
|
|
21
|
-
const http = createServer((req, res) => {
|
|
22
|
-
const url = parse(req.url);
|
|
23
|
-
// write cors headers for incoming host
|
|
24
|
-
const origin = req.headers.origin;
|
|
25
|
-
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
|
|
26
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
27
|
-
res.setHeader(
|
|
28
|
-
'Access-Control-Allow-Headers',
|
|
29
|
-
'Content-Type, Authorization, X-Request-Id',
|
|
30
|
-
);
|
|
31
|
-
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
32
|
-
res.setHeader('Access-Control-Max-Age', '86400');
|
|
33
|
-
if (req.method === 'OPTIONS') {
|
|
34
|
-
res.writeHead(200);
|
|
35
|
-
res.end();
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// serve files on the /files path
|
|
40
|
-
if (url.pathname.startsWith('/files/')) {
|
|
41
|
-
const fileStream = createReadStream(`files/${url.pathname.slice(7)}`);
|
|
42
|
-
fileStream.pipe(res);
|
|
43
|
-
fileStream.on('error', (err) => {
|
|
44
|
-
res.writeHead(404);
|
|
45
|
-
res.end();
|
|
46
|
-
});
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// provide tokens on the /auth path
|
|
51
|
-
if (url.pathname.startsWith('/auth/')) {
|
|
52
|
-
// userId is provided as a query param
|
|
53
|
-
const params = new URLSearchParams(url.search);
|
|
54
|
-
const userId = params.get('userId');
|
|
55
|
-
|
|
56
|
-
// library ID is in path
|
|
57
|
-
const libraryId = url.pathname.slice(6);
|
|
58
|
-
|
|
59
|
-
const token = tokenProvider.getToken({
|
|
60
|
-
userId,
|
|
61
|
-
libraryId,
|
|
62
|
-
syncEndpoint: `http://localhost:${PORT}/sync`,
|
|
63
|
-
role: 'user',
|
|
64
|
-
type: ReplicaType.Realtime,
|
|
65
|
-
});
|
|
66
|
-
res.writeHead(200, {
|
|
67
|
-
'Content-Type': 'application/json',
|
|
68
|
-
});
|
|
69
|
-
res.end(JSON.stringify({ accessToken: token }));
|
|
70
|
-
return;
|
|
71
|
-
} else if (url.pathname.startsWith('/sync')) {
|
|
72
|
-
if (url.pathname.startsWith('/sync/files/')) {
|
|
73
|
-
return verdant.handleFileRequest(req, res);
|
|
74
|
-
}
|
|
75
|
-
return verdant.handleRequest(req, res);
|
|
76
|
-
} else {
|
|
77
|
-
res.writeHead(404);
|
|
78
|
-
res.end();
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const verdant = new Server({
|
|
83
|
-
databaseFile: 'verdant.sqlite',
|
|
84
|
-
tokenSecret: SECRET,
|
|
85
|
-
fileStorage: new LocalFileStorage({
|
|
86
|
-
rootDirectory: 'files',
|
|
87
|
-
host: `http://localhost:${3242}/files`,
|
|
88
|
-
}),
|
|
89
|
-
profiles: {
|
|
90
|
-
get: (userId) => ({
|
|
91
|
-
id: userId,
|
|
92
|
-
}),
|
|
93
|
-
},
|
|
94
|
-
httpServer: http,
|
|
95
|
-
log: console.log,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
verdant.on('error', (err) => {
|
|
99
|
-
console.error(err);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
http.listen(PORT, () => {
|
|
103
|
-
console.log(`🌿 Verdant standalone server listening on port ${PORT}`);
|
|
104
|
-
console.log(``);
|
|
105
|
-
console.log(
|
|
106
|
-
`It's not recommended you use this server in production. It's a convenient way to test syncing locally, but has no authorization.`,
|
|
107
|
-
);
|
|
108
|
-
console.log(``);
|
|
109
|
-
console.log(
|
|
110
|
-
`To connect your client, supply "http://localhost:${PORT}/auth/<libraryId>?userId=<userId>" to sync.authEndpoint.`,
|
|
111
|
-
);
|
|
112
|
-
console.log(
|
|
113
|
-
`<libraryId> and <userId> are up to you and your app. Clients on the same library ID will sync data.`,
|
|
114
|
-
);
|
|
115
|
-
});
|
package/dist/cjs/Baselines.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { DocumentBaseline, Operation, Ref } from '@verdant-web/common';
|
|
2
|
-
import { Database } from 'better-sqlite3';
|
|
3
|
-
export declare class Baselines {
|
|
4
|
-
private db;
|
|
5
|
-
constructor(db: Database);
|
|
6
|
-
get: (libraryId: string, oid: string) => DocumentBaseline<any> | null;
|
|
7
|
-
getAllWithSubObjects: (libraryId: string, oid: string) => DocumentBaseline<any>[];
|
|
8
|
-
set: (libraryId: string, baseline: DocumentBaseline) => import("better-sqlite3").RunResult;
|
|
9
|
-
insertAll: (libraryId: string, baselines: DocumentBaseline[]) => void;
|
|
10
|
-
private hydrateSnapshot;
|
|
11
|
-
getAllAfter: (libraryId: string, timestamp: string | null) => DocumentBaseline<any>[];
|
|
12
|
-
getFromServerOrder: (libraryId: string, serverOrder: number) => DocumentBaseline<any>[];
|
|
13
|
-
applyOperations: (libraryId: string, oid: string, operations: Operation[], deletedRefs?: Ref[]) => never[] | undefined;
|
|
14
|
-
deleteAll: (libraryId: string) => void;
|
|
15
|
-
getCount: (libraryId: string) => number;
|
|
16
|
-
}
|