@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.
Files changed (198) hide show
  1. package/dist/esm/ClientConnection.d.ts +1 -0
  2. package/dist/esm/ClientConnection.js.map +1 -1
  3. package/dist/esm/Presence.js.map +1 -1
  4. package/dist/esm/Profiles.js.map +1 -1
  5. package/dist/esm/ReplicaKeepaliveTimers.js.map +1 -1
  6. package/dist/esm/Server.d.ts +15 -11
  7. package/dist/esm/Server.js +29 -30
  8. package/dist/esm/Server.js.map +1 -1
  9. package/dist/esm/ServerLibrary.d.ts +10 -17
  10. package/dist/esm/ServerLibrary.js +151 -127
  11. package/dist/esm/ServerLibrary.js.map +1 -1
  12. package/dist/esm/ServerLibrary.test.js +369 -0
  13. package/dist/esm/ServerLibrary.test.js.map +1 -0
  14. package/dist/esm/TokenVerifier.js.map +1 -1
  15. package/dist/esm/files/FileStorage.js.map +1 -1
  16. package/dist/esm/storage/Storage.d.ts +67 -0
  17. package/dist/esm/storage/Storage.js +2 -0
  18. package/dist/esm/storage/Storage.js.map +1 -0
  19. package/dist/esm/storage/index.d.ts +2 -0
  20. package/dist/esm/storage/index.js +3 -0
  21. package/dist/esm/storage/index.js.map +1 -0
  22. package/dist/esm/storage/sql/SqlBaselines.d.ts +22 -0
  23. package/dist/esm/storage/sql/SqlBaselines.js +101 -0
  24. package/dist/esm/storage/sql/SqlBaselines.js.map +1 -0
  25. package/dist/esm/storage/sql/SqlFileMetadata.d.ts +18 -0
  26. package/dist/esm/storage/sql/SqlFileMetadata.js +69 -0
  27. package/dist/esm/storage/sql/SqlFileMetadata.js.map +1 -0
  28. package/dist/esm/storage/sql/SqlOperations.d.ts +19 -0
  29. package/dist/esm/storage/sql/SqlOperations.js +104 -0
  30. package/dist/esm/storage/sql/SqlOperations.js.map +1 -0
  31. package/dist/esm/storage/sql/SqlReplicas.d.ts +32 -0
  32. package/dist/esm/storage/sql/SqlReplicas.js +163 -0
  33. package/dist/esm/storage/sql/SqlReplicas.js.map +1 -0
  34. package/dist/esm/storage/sql/migrations/v1.d.ts +3 -0
  35. package/dist/esm/storage/sql/migrations/v1.js +59 -0
  36. package/dist/esm/storage/sql/migrations/v1.js.map +1 -0
  37. package/dist/esm/storage/sql/migrations.d.ts +5 -0
  38. package/dist/esm/storage/sql/migrations.js +3 -0
  39. package/dist/esm/storage/sql/migrations.js.map +1 -0
  40. package/dist/esm/storage/sql/sqlStorage.d.ts +5 -0
  41. package/dist/esm/storage/sql/sqlStorage.js +43 -0
  42. package/dist/esm/storage/sql/sqlStorage.js.map +1 -0
  43. package/dist/esm/storage/sql/tables.d.ts +40 -0
  44. package/dist/esm/storage/sql/tables.js +2 -0
  45. package/dist/esm/storage/sql/tables.js.map +1 -0
  46. package/dist/esm/types.d.ts +14 -5
  47. package/package.json +20 -14
  48. package/src/Server.ts +37 -43
  49. package/src/ServerLibrary.test.ts +447 -0
  50. package/src/ServerLibrary.ts +239 -163
  51. package/src/storage/Storage.ts +118 -0
  52. package/src/storage/index.ts +2 -0
  53. package/src/storage/sql/SqlBaselines.ts +141 -0
  54. package/src/storage/sql/SqlFileMetadata.ts +94 -0
  55. package/src/storage/sql/SqlOperations.ts +149 -0
  56. package/src/storage/sql/SqlReplicas.ts +234 -0
  57. package/src/storage/sql/migrations/v1.ts +64 -0
  58. package/src/storage/sql/migrations.ts +3 -0
  59. package/src/storage/sql/sqlStorage.ts +59 -0
  60. package/src/storage/sql/tables.ts +45 -0
  61. package/src/types.ts +15 -4
  62. package/bin/server.mjs +0 -115
  63. package/dist/cjs/Baselines.d.ts +0 -16
  64. package/dist/cjs/Baselines.js +0 -128
  65. package/dist/cjs/Baselines.js.map +0 -1
  66. package/dist/cjs/ClientConnection.d.ts +0 -20
  67. package/dist/cjs/ClientConnection.js +0 -139
  68. package/dist/cjs/ClientConnection.js.map +0 -1
  69. package/dist/cjs/MessageSender.d.ts +0 -5
  70. package/dist/cjs/MessageSender.js +0 -3
  71. package/dist/cjs/MessageSender.js.map +0 -1
  72. package/dist/cjs/OperationHistory.d.ts +0 -36
  73. package/dist/cjs/OperationHistory.js +0 -165
  74. package/dist/cjs/OperationHistory.js.map +0 -1
  75. package/dist/cjs/Presence.d.ts +0 -15
  76. package/dist/cjs/Presence.js +0 -60
  77. package/dist/cjs/Presence.js.map +0 -1
  78. package/dist/cjs/Profiles.d.ts +0 -10
  79. package/dist/cjs/Profiles.js +0 -20
  80. package/dist/cjs/Profiles.js.map +0 -1
  81. package/dist/cjs/ReplicaKeepaliveTimers.d.ts +0 -17
  82. package/dist/cjs/ReplicaKeepaliveTimers.js +0 -31
  83. package/dist/cjs/ReplicaKeepaliveTimers.js.map +0 -1
  84. package/dist/cjs/Replicas.d.ts +0 -25
  85. package/dist/cjs/Replicas.js +0 -196
  86. package/dist/cjs/Replicas.js.map +0 -1
  87. package/dist/cjs/Server.d.ts +0 -167
  88. package/dist/cjs/Server.js +0 -552
  89. package/dist/cjs/Server.js.map +0 -1
  90. package/dist/cjs/ServerLibrary.d.ts +0 -65
  91. package/dist/cjs/ServerLibrary.js +0 -505
  92. package/dist/cjs/ServerLibrary.js.map +0 -1
  93. package/dist/cjs/TokenProvider.d.ts +0 -19
  94. package/dist/cjs/TokenProvider.js +0 -27
  95. package/dist/cjs/TokenProvider.js.map +0 -1
  96. package/dist/cjs/TokenVerifier.d.ts +0 -16
  97. package/dist/cjs/TokenVerifier.js +0 -40
  98. package/dist/cjs/TokenVerifier.js.map +0 -1
  99. package/dist/cjs/files/FileMetadata.d.ts +0 -28
  100. package/dist/cjs/files/FileMetadata.js +0 -69
  101. package/dist/cjs/files/FileMetadata.js.map +0 -1
  102. package/dist/cjs/files/FileStorage.d.ts +0 -35
  103. package/dist/cjs/files/FileStorage.js +0 -72
  104. package/dist/cjs/files/FileStorage.js.map +0 -1
  105. package/dist/cjs/index.d.ts +0 -10
  106. package/dist/cjs/index.js +0 -12
  107. package/dist/cjs/index.js.map +0 -1
  108. package/dist/cjs/migrations/v0.d.ts +0 -1
  109. package/dist/cjs/migrations/v0.js +0 -30
  110. package/dist/cjs/migrations/v0.js.map +0 -1
  111. package/dist/cjs/migrations/v1.d.ts +0 -1
  112. package/dist/cjs/migrations/v1.js +0 -32
  113. package/dist/cjs/migrations/v1.js.map +0 -1
  114. package/dist/cjs/migrations/v2.d.ts +0 -1
  115. package/dist/cjs/migrations/v2.js +0 -29
  116. package/dist/cjs/migrations/v2.js.map +0 -1
  117. package/dist/cjs/migrations/v3.d.ts +0 -1
  118. package/dist/cjs/migrations/v3.js +0 -15
  119. package/dist/cjs/migrations/v3.js.map +0 -1
  120. package/dist/cjs/migrations/v4.d.ts +0 -1
  121. package/dist/cjs/migrations/v4.js +0 -9
  122. package/dist/cjs/migrations/v4.js.map +0 -1
  123. package/dist/cjs/migrations/v5.d.ts +0 -2
  124. package/dist/cjs/migrations/v5.js +0 -37
  125. package/dist/cjs/migrations/v5.js.map +0 -1
  126. package/dist/cjs/migrations/v6.d.ts +0 -1
  127. package/dist/cjs/migrations/v6.js +0 -8
  128. package/dist/cjs/migrations/v6.js.map +0 -1
  129. package/dist/cjs/migrations/v7.d.ts +0 -1
  130. package/dist/cjs/migrations/v7.js +0 -10
  131. package/dist/cjs/migrations/v7.js.map +0 -1
  132. package/dist/cjs/migrations.d.ts +0 -6
  133. package/dist/cjs/migrations.js +0 -72
  134. package/dist/cjs/migrations.js.map +0 -1
  135. package/dist/cjs/migrations.test.js +0 -121
  136. package/dist/cjs/migrations.test.js.map +0 -1
  137. package/dist/cjs/types.d.ts +0 -38
  138. package/dist/cjs/types.js +0 -3
  139. package/dist/cjs/types.js.map +0 -1
  140. package/dist/esm/Baselines.d.ts +0 -16
  141. package/dist/esm/Baselines.js +0 -124
  142. package/dist/esm/Baselines.js.map +0 -1
  143. package/dist/esm/OperationHistory.d.ts +0 -36
  144. package/dist/esm/OperationHistory.js +0 -161
  145. package/dist/esm/OperationHistory.js.map +0 -1
  146. package/dist/esm/Replicas.d.ts +0 -25
  147. package/dist/esm/Replicas.js +0 -192
  148. package/dist/esm/Replicas.js.map +0 -1
  149. package/dist/esm/files/FileMetadata.d.ts +0 -28
  150. package/dist/esm/files/FileMetadata.js +0 -65
  151. package/dist/esm/files/FileMetadata.js.map +0 -1
  152. package/dist/esm/migrations/v0.d.ts +0 -1
  153. package/dist/esm/migrations/v0.js +0 -27
  154. package/dist/esm/migrations/v0.js.map +0 -1
  155. package/dist/esm/migrations/v1.d.ts +0 -1
  156. package/dist/esm/migrations/v1.js +0 -29
  157. package/dist/esm/migrations/v1.js.map +0 -1
  158. package/dist/esm/migrations/v2.d.ts +0 -1
  159. package/dist/esm/migrations/v2.js +0 -26
  160. package/dist/esm/migrations/v2.js.map +0 -1
  161. package/dist/esm/migrations/v3.d.ts +0 -1
  162. package/dist/esm/migrations/v3.js +0 -12
  163. package/dist/esm/migrations/v3.js.map +0 -1
  164. package/dist/esm/migrations/v4.d.ts +0 -1
  165. package/dist/esm/migrations/v4.js +0 -6
  166. package/dist/esm/migrations/v4.js.map +0 -1
  167. package/dist/esm/migrations/v5.d.ts +0 -2
  168. package/dist/esm/migrations/v5.js +0 -33
  169. package/dist/esm/migrations/v5.js.map +0 -1
  170. package/dist/esm/migrations/v6.d.ts +0 -1
  171. package/dist/esm/migrations/v6.js +0 -5
  172. package/dist/esm/migrations/v6.js.map +0 -1
  173. package/dist/esm/migrations/v7.d.ts +0 -1
  174. package/dist/esm/migrations/v7.js +0 -7
  175. package/dist/esm/migrations/v7.js.map +0 -1
  176. package/dist/esm/migrations.d.ts +0 -6
  177. package/dist/esm/migrations.js +0 -45
  178. package/dist/esm/migrations.js.map +0 -1
  179. package/dist/esm/migrations.test.d.ts +0 -1
  180. package/dist/esm/migrations.test.js +0 -116
  181. package/dist/esm/migrations.test.js.map +0 -1
  182. package/dist/tsconfig-cjs.tsbuildinfo +0 -1
  183. package/dist/tsconfig.tsbuildinfo +0 -1
  184. package/src/Baselines.ts +0 -198
  185. package/src/OperationHistory.ts +0 -232
  186. package/src/Replicas.ts +0 -274
  187. package/src/files/FileMetadata.ts +0 -112
  188. package/src/migrations/v0.ts +0 -26
  189. package/src/migrations/v1.ts +0 -28
  190. package/src/migrations/v2.ts +0 -26
  191. package/src/migrations/v3.ts +0 -12
  192. package/src/migrations/v4.ts +0 -6
  193. package/src/migrations/v5.ts +0 -49
  194. package/src/migrations/v6.ts +0 -4
  195. package/src/migrations/v7.ts +0 -6
  196. package/src/migrations.test.ts +0 -133
  197. package/src/migrations.ts +0 -61
  198. /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,3 @@
1
+ import * as v1 from './migrations/v1.js';
2
+
3
+ export default { v1 };
@@ -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 OperationSpec extends Operation {
8
+ export interface StoredOperation extends Operation {
9
9
  serverOrder: number;
10
10
  replicaId: string;
11
11
  }
12
12
 
13
- export interface DocumentBaselineSpec
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 ReplicaInfoSpec extends ReplicaInfo {
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
- });
@@ -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
- }