@gamerdexapp/gamerdex-sync 0.0.0-stage → 0.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/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/dist/index.d.mts +392 -0
- package/dist/index.d.ts +392 -0
- package/dist/index.js +829 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +767 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +62 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
declare const ROOT_FOLDER = "Gamerdex";
|
|
2
|
+
declare const DATA_FILE = "data.json";
|
|
3
|
+
declare const META_FILE = "meta.json";
|
|
4
|
+
/** An app id names a folder, so it is lowercase words joined by single dashes. */
|
|
5
|
+
declare const isAppId: (value: string) => boolean;
|
|
6
|
+
/** The path of the saved data of an app, such as `Gamerdex/whiteout-survival/data.json`. */
|
|
7
|
+
declare const dataPath: (appId: string) => string;
|
|
8
|
+
/** The path of the bookkeeping file of an app, next to its saved data. */
|
|
9
|
+
declare const metaPath: (appId: string) => string;
|
|
10
|
+
|
|
11
|
+
declare const META_FORMAT = 1;
|
|
12
|
+
interface DeviceRecord {
|
|
13
|
+
name: string;
|
|
14
|
+
/** ISO time of the last sync of the device. */
|
|
15
|
+
lastSeenAt: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The bookkeeping file beside the saved data. It holds no game data and no token. A revision only
|
|
19
|
+
* counts up, so a device with a wrong clock cannot make old data look new.
|
|
20
|
+
*/
|
|
21
|
+
interface SyncMeta {
|
|
22
|
+
format: typeof META_FORMAT;
|
|
23
|
+
appId: string;
|
|
24
|
+
/** One counter for each chief id. A chief that was removed keeps its counter. */
|
|
25
|
+
revisions: Record<string, number>;
|
|
26
|
+
settingsRevision: number;
|
|
27
|
+
/** Chief ids removed on purpose, with the revision of the removal. */
|
|
28
|
+
removed: Record<string, number>;
|
|
29
|
+
devices: Record<string, DeviceRecord>;
|
|
30
|
+
lastSyncAt: string | null;
|
|
31
|
+
}
|
|
32
|
+
type ParsedMeta = {
|
|
33
|
+
ok: true;
|
|
34
|
+
meta: SyncMeta;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
error: string;
|
|
38
|
+
};
|
|
39
|
+
declare const emptyMeta: (appId: string) => SyncMeta;
|
|
40
|
+
/** Reads the meta file and checks the whole shape. Nothing is returned for a file that fails. */
|
|
41
|
+
declare const parseMeta: (text: string, appId: string) => ParsedMeta;
|
|
42
|
+
/** Counts one change of a chief. A chief that returns after a removal leaves the removed list. */
|
|
43
|
+
declare const withChiefRevision: (meta: SyncMeta, chiefId: string) => SyncMeta;
|
|
44
|
+
/** Counts the removal of a chief. The counter keeps rising so an old copy cannot return it. */
|
|
45
|
+
declare const withChiefRemoved: (meta: SyncMeta, chiefId: string) => SyncMeta;
|
|
46
|
+
declare const withSettingsRevision: (meta: SyncMeta) => SyncMeta;
|
|
47
|
+
declare const withDevice: (meta: SyncMeta, deviceId: string, device: DeviceRecord) => SyncMeta;
|
|
48
|
+
|
|
49
|
+
declare const isObject: (value: unknown) => value is Record<string, unknown>;
|
|
50
|
+
/** Deep equality of two JSON values. The order of the keys of an object does not matter. */
|
|
51
|
+
declare const equal: (a: unknown, b: unknown) => boolean;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A step of a path into the saved data. A key of an object, or the id of an entry of a keyed list.
|
|
55
|
+
*/
|
|
56
|
+
type PathStep = string;
|
|
57
|
+
type Path = PathStep[];
|
|
58
|
+
/**
|
|
59
|
+
* Says how a part of the saved data merges. The app writes one schema for its saved data.
|
|
60
|
+
*
|
|
61
|
+
* - `record`: a value that cannot merge. If one side changed it, that side wins. If both changed it,
|
|
62
|
+
* it is a conflict.
|
|
63
|
+
* - `max`: a level. When both sides changed it, the higher one by `order` wins.
|
|
64
|
+
* - `join`: a list of plain values. The result holds what both lists hold and what either side
|
|
65
|
+
* added, and drops what either side removed since the last sync.
|
|
66
|
+
* - `local`: a value of this device. The merge keeps the local value and ignores the other side.
|
|
67
|
+
* - `object`: a map of entries. Each entry merges with its own schema. `fields` names the entries
|
|
68
|
+
* that differ, `each` covers the others, and an entry with no schema is a `record`. With
|
|
69
|
+
* `deleteWins`, a removed entry wins over a change of the other side inside this map.
|
|
70
|
+
* - `keyed`: a list of objects that each have a string `key`, such as a list of chiefs with an id.
|
|
71
|
+
* Its entries merge like the entries of an `object`.
|
|
72
|
+
*/
|
|
73
|
+
type Schema = {
|
|
74
|
+
type: 'record';
|
|
75
|
+
} | {
|
|
76
|
+
type: 'max';
|
|
77
|
+
order: (value: unknown) => number;
|
|
78
|
+
} | {
|
|
79
|
+
type: 'join';
|
|
80
|
+
} | {
|
|
81
|
+
type: 'local';
|
|
82
|
+
} | {
|
|
83
|
+
type: 'object';
|
|
84
|
+
fields?: Record<string, Schema>;
|
|
85
|
+
each?: Schema;
|
|
86
|
+
deleteWins?: boolean;
|
|
87
|
+
} | {
|
|
88
|
+
type: 'keyed';
|
|
89
|
+
key: string;
|
|
90
|
+
item: Schema;
|
|
91
|
+
deleteWins?: boolean;
|
|
92
|
+
};
|
|
93
|
+
type Side = 'local' | 'remote';
|
|
94
|
+
type ConflictKind = 'both-changed' | 'delete-edit';
|
|
95
|
+
/**
|
|
96
|
+
* A change that the merge could not settle by itself. The merged data already holds the value of
|
|
97
|
+
* `taken`, so nothing is lost. The player can pick the other side with `resolveConflict`.
|
|
98
|
+
*/
|
|
99
|
+
interface Conflict {
|
|
100
|
+
path: Path;
|
|
101
|
+
kind: ConflictKind;
|
|
102
|
+
base: unknown;
|
|
103
|
+
local: unknown;
|
|
104
|
+
remote: unknown;
|
|
105
|
+
taken: Side;
|
|
106
|
+
}
|
|
107
|
+
interface MergeInput {
|
|
108
|
+
/** The data at the last sync. Undefined when this device never synced. */
|
|
109
|
+
base: unknown;
|
|
110
|
+
local: unknown;
|
|
111
|
+
remote: unknown;
|
|
112
|
+
schema: Schema;
|
|
113
|
+
/** The side that wins a conflict until the player picks. The default is the remote side. */
|
|
114
|
+
prefer?: Side;
|
|
115
|
+
}
|
|
116
|
+
interface MergeResult {
|
|
117
|
+
/** The data for this device. It keeps the values of this device. */
|
|
118
|
+
merged: unknown;
|
|
119
|
+
/**
|
|
120
|
+
* The data for the cloud. It is `merged`, except that a part with the `local` strategy keeps the
|
|
121
|
+
* value of the cloud, so a value of one device never changes the cloud by itself.
|
|
122
|
+
*/
|
|
123
|
+
cloud: unknown;
|
|
124
|
+
conflicts: Conflict[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Merges the data of this device and the data of the cloud against the data of the last sync.
|
|
129
|
+
* A part that only one side changed takes that change. A part that both sides changed merges by its
|
|
130
|
+
* schema, and a part that cannot merge is reported in `conflicts` with the preferred side already
|
|
131
|
+
* in `merged`.
|
|
132
|
+
*/
|
|
133
|
+
declare const merge: ({ base, local, remote, schema, prefer, }: MergeInput) => MergeResult;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Puts the value of one side of a conflict into the merged data. The player uses this to pick the
|
|
137
|
+
* side that the merge did not take. A side that holds no value removes the entry.
|
|
138
|
+
*/
|
|
139
|
+
declare const resolveConflict: (merged: unknown, schema: Schema, conflict: Conflict, side: Side) => unknown;
|
|
140
|
+
|
|
141
|
+
/** Encodes text as base64 from its UTF-8 bytes. It works in the browser and in Node. */
|
|
142
|
+
declare const encodeBase64: (text: string) => string;
|
|
143
|
+
/** Decodes base64 to text. Line breaks in the base64 text are ignored. */
|
|
144
|
+
declare const decodeBase64: (base64: string) => string;
|
|
145
|
+
|
|
146
|
+
/** A file in the cloud. The version changes with every write and settles which write is newer. */
|
|
147
|
+
interface RemoteFile {
|
|
148
|
+
content: string;
|
|
149
|
+
version: string;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Where the files of an app are kept. A provider reads, writes and removes whole files by path, and
|
|
153
|
+
* refuses a write that was made from an old version of the file. That refusal is what keeps two
|
|
154
|
+
* devices from overwriting each other.
|
|
155
|
+
*/
|
|
156
|
+
interface SyncProvider {
|
|
157
|
+
/** Returns null when the file does not exist. */
|
|
158
|
+
read(path: string): Promise<RemoteFile | null>;
|
|
159
|
+
/**
|
|
160
|
+
* Writes the file. The expected version is the version that was read, or null to create a file
|
|
161
|
+
* that must not exist yet. Throws `VersionConflictError` when the file changed since.
|
|
162
|
+
*/
|
|
163
|
+
write(path: string, content: string, expectedVersion: string | null): Promise<{
|
|
164
|
+
version: string;
|
|
165
|
+
}>;
|
|
166
|
+
/** Removes the file. Throws `VersionConflictError` when the file changed since. */
|
|
167
|
+
remove(path: string, expectedVersion: string): Promise<void>;
|
|
168
|
+
}
|
|
169
|
+
/** The file changed in the cloud after it was read. Read it again and merge. */
|
|
170
|
+
declare class VersionConflictError extends Error {
|
|
171
|
+
readonly path: string;
|
|
172
|
+
constructor(path: string);
|
|
173
|
+
}
|
|
174
|
+
type ProviderErrorKind =
|
|
175
|
+
/** The token is missing, expired, revoked or has no permission. The player signs in again. */
|
|
176
|
+
'auth'
|
|
177
|
+
/** The provider limits the calls for now. Try again after `retryAfterSeconds`. */
|
|
178
|
+
| 'rate-limit'
|
|
179
|
+
/** The repository or folder does not exist, or the token cannot see it. */
|
|
180
|
+
| 'not-found'
|
|
181
|
+
/** The device is offline or the provider cannot be reached. */
|
|
182
|
+
| 'network'
|
|
183
|
+
/** The provider failed. A later try may work. */
|
|
184
|
+
| 'server'
|
|
185
|
+
/** The provider answered with something this package does not understand. */
|
|
186
|
+
| 'invalid';
|
|
187
|
+
declare class ProviderError extends Error {
|
|
188
|
+
readonly kind: ProviderErrorKind;
|
|
189
|
+
readonly status?: number | undefined;
|
|
190
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
191
|
+
constructor(kind: ProviderErrorKind, message: string, status?: number | undefined, retryAfterSeconds?: number | undefined);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface GitHubOptions {
|
|
195
|
+
/** The owner and the name of the repository, such as `chiefpansancolt/gamerdex-data`. */
|
|
196
|
+
repository: string;
|
|
197
|
+
/** Returns the token. It may refresh the token first, so it is called for every request. */
|
|
198
|
+
getToken: () => Promise<string>;
|
|
199
|
+
/** The branch to use. Without it GitHub uses the default branch of the repository. */
|
|
200
|
+
branch?: string;
|
|
201
|
+
/** The message of the commit that a write or a removal makes. */
|
|
202
|
+
commitMessage?: (path: string) => string;
|
|
203
|
+
fetch?: typeof fetch;
|
|
204
|
+
}
|
|
205
|
+
interface RepositoryCheck {
|
|
206
|
+
private: boolean;
|
|
207
|
+
canWrite: boolean;
|
|
208
|
+
defaultBranch: string;
|
|
209
|
+
}
|
|
210
|
+
/** Keeps the files of an app in a private GitHub repository, through the contents API. */
|
|
211
|
+
declare class GitHubProvider implements SyncProvider {
|
|
212
|
+
private readonly options;
|
|
213
|
+
private readonly send;
|
|
214
|
+
constructor(options: GitHubOptions);
|
|
215
|
+
read(path: string): Promise<RemoteFile | null>;
|
|
216
|
+
write(path: string, content: string, expectedVersion: string | null): Promise<{
|
|
217
|
+
version: string;
|
|
218
|
+
}>;
|
|
219
|
+
remove(path: string, expectedVersion: string): Promise<void>;
|
|
220
|
+
/**
|
|
221
|
+
* Checks that the repository can be used: it exists, the token can write to it, and it is
|
|
222
|
+
* private. The saved data of a player must never sit in a public repository.
|
|
223
|
+
*/
|
|
224
|
+
checkRepository(): Promise<RepositoryCheck>;
|
|
225
|
+
private contentsUrl;
|
|
226
|
+
private message;
|
|
227
|
+
private request;
|
|
228
|
+
private assertOk;
|
|
229
|
+
private invalid;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A provider that keeps the files in memory. It follows the same version rules as a real provider,
|
|
234
|
+
* so the sync flow of an app can be tested without a network.
|
|
235
|
+
*/
|
|
236
|
+
declare class MemoryProvider implements SyncProvider {
|
|
237
|
+
private files;
|
|
238
|
+
private counter;
|
|
239
|
+
read(path: string): Promise<RemoteFile | null>;
|
|
240
|
+
write(path: string, content: string, expectedVersion: string | null): Promise<{
|
|
241
|
+
version: string;
|
|
242
|
+
}>;
|
|
243
|
+
remove(path: string, expectedVersion: string): Promise<void>;
|
|
244
|
+
/** The paths of the files that exist, in the order they were first written. */
|
|
245
|
+
paths(): string[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What this device remembers about the last sync. The app keeps it in the browser, next to the
|
|
250
|
+
* saved data but outside it, and passes it back on the next sync.
|
|
251
|
+
*/
|
|
252
|
+
interface SyncState {
|
|
253
|
+
/** The cloud data as this device last saw it. */
|
|
254
|
+
base: unknown;
|
|
255
|
+
dataVersion: string | null;
|
|
256
|
+
metaVersion: string | null;
|
|
257
|
+
}
|
|
258
|
+
/** What to do when this device never synced and both the device and the cloud hold data. */
|
|
259
|
+
type FirstSyncChoice = 'merge' | 'keep-local' | 'keep-cloud';
|
|
260
|
+
interface SyncOptions {
|
|
261
|
+
appId: string;
|
|
262
|
+
deviceId: string;
|
|
263
|
+
deviceName: string;
|
|
264
|
+
provider: SyncProvider;
|
|
265
|
+
schema: Schema;
|
|
266
|
+
/** The data of this device. */
|
|
267
|
+
local: unknown;
|
|
268
|
+
/** The state kept after the last sync, or null when this device never synced. */
|
|
269
|
+
state: SyncState | null;
|
|
270
|
+
firstSync?: FirstSyncChoice;
|
|
271
|
+
/** Returns true when the data is whole and safe to store. Called before any write. */
|
|
272
|
+
validate?: (data: unknown) => boolean;
|
|
273
|
+
/** Lets the app change the revisions of the meta file for this sync. */
|
|
274
|
+
updateMeta?: (meta: SyncMeta) => SyncMeta;
|
|
275
|
+
/** The current time as an ISO text. */
|
|
276
|
+
now: () => string;
|
|
277
|
+
}
|
|
278
|
+
type SyncResult = {
|
|
279
|
+
status: 'synced';
|
|
280
|
+
/** The data to store on this device. */
|
|
281
|
+
data: unknown;
|
|
282
|
+
/** The state to keep for the next sync. */
|
|
283
|
+
state: SyncState;
|
|
284
|
+
conflicts: Conflict[];
|
|
285
|
+
/** True when the cloud changed during this sync. */
|
|
286
|
+
uploaded: boolean;
|
|
287
|
+
} | {
|
|
288
|
+
/** The device and the cloud both hold data and the app has not said what to do. */
|
|
289
|
+
status: 'needs-choice';
|
|
290
|
+
local: unknown;
|
|
291
|
+
remote: unknown;
|
|
292
|
+
};
|
|
293
|
+
/** The data in the cloud is not whole, so nothing was written. */
|
|
294
|
+
declare class RemoteDataError extends Error {
|
|
295
|
+
constructor(message: string);
|
|
296
|
+
}
|
|
297
|
+
/** Other devices kept writing during every try, so the sync stopped. Try again later. */
|
|
298
|
+
declare class SyncBusyError extends Error {
|
|
299
|
+
constructor();
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Brings this device and the cloud to the same data. It reads the cloud, merges the three copies,
|
|
303
|
+
* writes the cloud copy when it changed, and returns the data to store on this device with the
|
|
304
|
+
* state to keep. It never changes the device by itself, and it does not write when the cloud data
|
|
305
|
+
* or the merged data fail `validate`.
|
|
306
|
+
*
|
|
307
|
+
* Throws `ProviderError` when the provider fails, `RemoteDataError` when the cloud data is not
|
|
308
|
+
* whole, and `SyncBusyError` when other devices keep writing.
|
|
309
|
+
*/
|
|
310
|
+
declare const sync: (options: SyncOptions) => Promise<SyncResult>;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Returns the merged data as it must be stored in the cloud: every part with the `local` strategy
|
|
314
|
+
* keeps the value that the cloud already holds, so a value of one device never changes the cloud.
|
|
315
|
+
*/
|
|
316
|
+
declare const restoreCloudValues: (merged: unknown, remote: unknown, schema: Schema) => unknown;
|
|
317
|
+
|
|
318
|
+
/** The public values of the Gamerdex Sync GitHub App. The client secret is never kept here. */
|
|
319
|
+
declare const GITHUB_APP: {
|
|
320
|
+
readonly slug: "gamerdex-sync";
|
|
321
|
+
readonly clientId: "Iv23liwzjj3x8O2K5ALA";
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/** The tokens of a signed in player. Times are milliseconds since 1970. */
|
|
325
|
+
interface StoredTokens {
|
|
326
|
+
accessToken: string;
|
|
327
|
+
accessExpiresAt: number;
|
|
328
|
+
refreshToken: string;
|
|
329
|
+
refreshExpiresAt: number;
|
|
330
|
+
}
|
|
331
|
+
/** The answer of the token function, with lifetimes in seconds. */
|
|
332
|
+
interface TokenResponse {
|
|
333
|
+
accessToken: string;
|
|
334
|
+
expiresIn: number;
|
|
335
|
+
refreshToken: string;
|
|
336
|
+
refreshExpiresIn: number;
|
|
337
|
+
}
|
|
338
|
+
declare const parseTokenResponse: (body: unknown) => TokenResponse | null;
|
|
339
|
+
declare const toStoredTokens: (response: TokenResponse, nowMs: number) => StoredTokens;
|
|
340
|
+
|
|
341
|
+
interface TokenSourceOptions {
|
|
342
|
+
/** The address of the token function, without a trailing slash. */
|
|
343
|
+
endpoint: string;
|
|
344
|
+
/** Reads the tokens that this device saved, or null when the player is signed out. */
|
|
345
|
+
load: () => StoredTokens | null;
|
|
346
|
+
/** Saves the tokens, or removes them when null. */
|
|
347
|
+
save: (tokens: StoredTokens | null) => void;
|
|
348
|
+
now: () => number;
|
|
349
|
+
fetch?: typeof fetch;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Keeps a player signed in. `getToken` returns a token that is valid for at least a minute and
|
|
353
|
+
* renews it first when needed. It is meant to be the `getToken` of `GitHubProvider`.
|
|
354
|
+
*/
|
|
355
|
+
declare const createTokenSource: (options: TokenSourceOptions) => {
|
|
356
|
+
/** Finishes a sign in with the code from the callback page. */
|
|
357
|
+
signIn: (code: string, redirectUri: string) => Promise<StoredTokens>;
|
|
358
|
+
signOut: () => void;
|
|
359
|
+
isSignedIn: () => boolean;
|
|
360
|
+
getToken: () => Promise<string>;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/** A random value that ties a sign-in answer to the sign-in that this device started. */
|
|
364
|
+
declare const newState: () => string;
|
|
365
|
+
declare const authorizeUrl: (redirectUri: string, state: string) => string;
|
|
366
|
+
/** The page where the player picks the repository that the app may use. */
|
|
367
|
+
declare const installUrl: (state: string) => string;
|
|
368
|
+
type CallbackResult = {
|
|
369
|
+
ok: true;
|
|
370
|
+
code: string;
|
|
371
|
+
} | {
|
|
372
|
+
ok: false;
|
|
373
|
+
reason: 'denied' | 'state' | 'missing';
|
|
374
|
+
};
|
|
375
|
+
/**
|
|
376
|
+
* Reads the query of the sign-in callback page. The state must equal the state that was saved when
|
|
377
|
+
* the sign-in started, or the answer is refused.
|
|
378
|
+
*/
|
|
379
|
+
declare const readCallback: (search: string, expectedState: string | null) => CallbackResult;
|
|
380
|
+
|
|
381
|
+
interface InstalledRepository {
|
|
382
|
+
/** The owner and the name, such as `chiefpansancolt/gamerdex-data`. */
|
|
383
|
+
fullName: string;
|
|
384
|
+
private: boolean;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Lists the repositories that the player gave to the GitHub App, for the first 100 installations
|
|
388
|
+
* and the first 100 repositories of each. The player picks the repository for the synced data.
|
|
389
|
+
*/
|
|
390
|
+
declare const listInstalledRepositories: (token: string, send?: typeof fetch) => Promise<InstalledRepository[]>;
|
|
391
|
+
|
|
392
|
+
export { type CallbackResult, type Conflict, type ConflictKind, DATA_FILE, type DeviceRecord, type FirstSyncChoice, GITHUB_APP, type GitHubOptions, GitHubProvider, type InstalledRepository, META_FILE, META_FORMAT, MemoryProvider, type MergeInput, type MergeResult, type ParsedMeta, type Path, type PathStep, ProviderError, type ProviderErrorKind, ROOT_FOLDER, RemoteDataError, type RemoteFile, type RepositoryCheck, type Schema, type Side, type StoredTokens, SyncBusyError, type SyncMeta, type SyncOptions, type SyncProvider, type SyncResult, type SyncState, type TokenResponse, type TokenSourceOptions, VersionConflictError, authorizeUrl, createTokenSource, dataPath, decodeBase64, emptyMeta, encodeBase64, equal, installUrl, isAppId, isObject, listInstalledRepositories, merge, metaPath, newState, parseMeta, parseTokenResponse, readCallback, resolveConflict, restoreCloudValues, sync, toStoredTokens, withChiefRemoved, withChiefRevision, withDevice, withSettingsRevision };
|