lexshift 0.0.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/README.md +47 -0
- package/dist/index.d.mts +96 -0
- package/dist/index.mjs +471 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# lexshift
|
|
2
|
+
|
|
3
|
+
`lexshift` helps you work with evolving AT Protocol lexicons by identifying record revisions and shifting records to a target revision.
|
|
4
|
+
|
|
5
|
+
It is designed for schema evolution workflows where records need to remain usable across multiple lexicon versions.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install lexshift
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## API overview
|
|
14
|
+
|
|
15
|
+
- `identify(record, options?)`: Detects which lexicon revision a record matches.
|
|
16
|
+
- `shift(record, targetRevision, options?)`: Migrates a record to a requested revision.
|
|
17
|
+
|
|
18
|
+
Both functions support a `historyProvider` option so you can supply historical lexicons when the current revision does not match.
|
|
19
|
+
|
|
20
|
+
## Example
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { identify, shift } from "lexshift";
|
|
24
|
+
|
|
25
|
+
const record = {
|
|
26
|
+
$type: "com.example.profile",
|
|
27
|
+
name: "Ada",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const historyProvider = async ({ nsid, current }) => {
|
|
31
|
+
const previousLexicons = await fetchHistorySomehow(nsid, current.revision);
|
|
32
|
+
return previousLexicons.map((candidate) => ({
|
|
33
|
+
revision: candidate.revision,
|
|
34
|
+
lexicon: candidate.lexicon,
|
|
35
|
+
cid: candidate.cid,
|
|
36
|
+
}));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const identified = await identify(record, { historyProvider });
|
|
40
|
+
|
|
41
|
+
const migrated = await shift(record, 3, { historyProvider });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
|
|
46
|
+
- `record.$type` must contain a valid NSID.
|
|
47
|
+
- If no matching revision is found, the functions throw an error.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { LexResolver } from "@atproto/lex-resolver";
|
|
2
|
+
import { LexRecord, LexiconDoc } from "@atproto/lexicon";
|
|
3
|
+
|
|
4
|
+
//#region src/types.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* An ATproto record containing a `$type` value.
|
|
7
|
+
*/
|
|
8
|
+
interface ATProtoRecord extends Record<string, unknown> {
|
|
9
|
+
$type: string;
|
|
10
|
+
}
|
|
11
|
+
interface RevisionedATProtoRecord<R extends ATProtoRecord> {
|
|
12
|
+
revision: number;
|
|
13
|
+
record: R;
|
|
14
|
+
}
|
|
15
|
+
interface RevisionedATProtoRecordWithHistory<R extends ATProtoRecord> extends RevisionedATProtoRecord<R> {
|
|
16
|
+
lexicons: Array<LexiconHistoryCandidate>;
|
|
17
|
+
}
|
|
18
|
+
interface ResolvedLexicon {
|
|
19
|
+
revision: number;
|
|
20
|
+
lexicon: LexiconDoc;
|
|
21
|
+
cid: string;
|
|
22
|
+
did: string;
|
|
23
|
+
uri: string;
|
|
24
|
+
}
|
|
25
|
+
interface LexiconHistoryCandidate {
|
|
26
|
+
revision?: number;
|
|
27
|
+
lexicon: unknown;
|
|
28
|
+
cid?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* An ATproto record along with its NSID and current lexicon revision. This data
|
|
32
|
+
* should be used to fetch previous lexicons.
|
|
33
|
+
*/
|
|
34
|
+
interface IdentifyHistoryProviderInput<R extends ATProtoRecord> {
|
|
35
|
+
nsid: string;
|
|
36
|
+
record: R;
|
|
37
|
+
current: ResolvedLexicon;
|
|
38
|
+
}
|
|
39
|
+
type IdentifyHistoryProvider<R extends ATProtoRecord> = (input: IdentifyHistoryProviderInput<R>) => Iterable<LexiconHistoryCandidate> | AsyncIterable<LexiconHistoryCandidate> | Promise<Iterable<LexiconHistoryCandidate> | AsyncIterable<LexiconHistoryCandidate>>;
|
|
40
|
+
interface LexshiftOptions<R extends ATProtoRecord> {
|
|
41
|
+
/**
|
|
42
|
+
* A function that takes in a given input and returns a list of lexicon history candidates.
|
|
43
|
+
*/
|
|
44
|
+
historyProvider?: IdentifyHistoryProvider<R>;
|
|
45
|
+
/**
|
|
46
|
+
* A function to resolve a lexicon document from the AT protocol network.
|
|
47
|
+
*/
|
|
48
|
+
resolver?: LexResolver;
|
|
49
|
+
}
|
|
50
|
+
interface KeyDifferences {
|
|
51
|
+
unchanged: Array<string>;
|
|
52
|
+
converted: Array<[string, LexRecordProperty, LexRecordProperty]>;
|
|
53
|
+
renamed: Array<[string, string]>;
|
|
54
|
+
dropped: Array<string>;
|
|
55
|
+
created: Array<[string, unknown]>;
|
|
56
|
+
}
|
|
57
|
+
type LexRecordProperty = LexRecord["record"]["properties"][string];
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/identify.d.ts
|
|
60
|
+
interface IdentifyOptions<R extends ATProtoRecord, ReturnLexicons extends boolean = false> extends LexshiftOptions<R> {
|
|
61
|
+
/**
|
|
62
|
+
* Whether the lexicons for the record should be returned as well.
|
|
63
|
+
*/
|
|
64
|
+
returnLexicons?: ReturnLexicons;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Identifies the lexicon revision for a given record by analyzing it's keys.
|
|
68
|
+
*
|
|
69
|
+
* First, this function attempts to fetch the current lexicon, verifies it, and matches the record against it.
|
|
70
|
+
* In a case where the record matches, it will return this revision of the lexicon.
|
|
71
|
+
*
|
|
72
|
+
* Should the record not match the lexicon and if there is an older revision, that older revision will be retreived.
|
|
73
|
+
* This happens until the first revision of the lexicon is reached, at which point the record either matches,
|
|
74
|
+
* or the function throws an error because it does not.
|
|
75
|
+
*
|
|
76
|
+
* @param record The record to get the revision for.
|
|
77
|
+
* @param options Options for resolving the lexicon, including historical data.
|
|
78
|
+
*/
|
|
79
|
+
declare function identify<R extends ATProtoRecord>(record: R, options: IdentifyOptions<R, true>): Promise<RevisionedATProtoRecordWithHistory<R>>;
|
|
80
|
+
declare function identify<R extends ATProtoRecord>(record: R, options?: IdentifyOptions<R, false>): Promise<RevisionedATProtoRecord<R>>;
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/shift.d.ts
|
|
83
|
+
/**
|
|
84
|
+
* Shifts a record from its current lexicon revision to another one, by either upshifting or downshifting.
|
|
85
|
+
*
|
|
86
|
+
* @param record The record to shift.
|
|
87
|
+
* @param newRevision The revision to shift to.
|
|
88
|
+
* @param options The options for this function.
|
|
89
|
+
*/
|
|
90
|
+
declare function shift<R extends ATProtoRecord, N extends number, S extends ATProtoRecord>(record: R, newRevision: N, options?: LexshiftOptions<R>): Promise<{
|
|
91
|
+
oldRevision: number;
|
|
92
|
+
newRevision: N;
|
|
93
|
+
record: S;
|
|
94
|
+
}>;
|
|
95
|
+
//#endregion
|
|
96
|
+
export { ATProtoRecord, IdentifyHistoryProvider, IdentifyHistoryProviderInput, KeyDifferences, LexRecordProperty, LexiconHistoryCandidate, LexshiftOptions, ResolvedLexicon, RevisionedATProtoRecord, RevisionedATProtoRecordWithHistory, identify, shift };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { LexResolver } from "@atproto/lex-resolver";
|
|
2
|
+
import { BlobRef, Lexicons, ValidationError, parseLexiconDoc } from "@atproto/lexicon";
|
|
3
|
+
//#region src/identify.ts
|
|
4
|
+
async function identify(record, options = {}) {
|
|
5
|
+
const normalizedNsid = record.$type.trim();
|
|
6
|
+
if (normalizedNsid.length === 0) throw new Error("NSID is required");
|
|
7
|
+
const currentResult = await (options.resolver ?? new LexResolver({})).get(normalizedNsid);
|
|
8
|
+
const currentLexicon = parseLexiconDoc(structuredClone(currentResult.lexicon));
|
|
9
|
+
if (currentLexicon.id !== normalizedNsid) throw new Error(`Resolved lexicon id ${currentLexicon.id} does not match requested NSID ${normalizedNsid}`);
|
|
10
|
+
const resolvedCurrent = {
|
|
11
|
+
revision: currentLexicon.revision ?? 1,
|
|
12
|
+
lexicon: currentLexicon,
|
|
13
|
+
cid: currentResult.cid.toString(),
|
|
14
|
+
did: currentResult.uri.host,
|
|
15
|
+
uri: currentResult.uri.toString()
|
|
16
|
+
};
|
|
17
|
+
const history = options.historyProvider ? await Array.fromAsync(await options.historyProvider({
|
|
18
|
+
nsid: normalizedNsid,
|
|
19
|
+
record,
|
|
20
|
+
current: resolvedCurrent
|
|
21
|
+
})) : void 0;
|
|
22
|
+
if (matchesLexicon(record, normalizedNsid, resolvedCurrent.lexicon)) {
|
|
23
|
+
if (options.returnLexicons === true) return {
|
|
24
|
+
revision: resolvedCurrent.revision,
|
|
25
|
+
record,
|
|
26
|
+
lexicons: history ? [...history, asCandidate(currentLexicon)] : []
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
revision: resolvedCurrent.revision,
|
|
30
|
+
record
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (resolvedCurrent.revision <= 1) throw new Error(`Record does not match lexicon ${normalizedNsid} at first revision`);
|
|
34
|
+
if (!history) throw new Error(`Record does not match current lexicon ${normalizedNsid} revision ${resolvedCurrent.revision} and no history provider was configured`);
|
|
35
|
+
for (const candidate of history) {
|
|
36
|
+
const historicalLexicon = parseLexiconDoc(structuredClone(candidate.lexicon));
|
|
37
|
+
if (historicalLexicon.id !== normalizedNsid) continue;
|
|
38
|
+
const candidateRevision = candidate.revision ?? historicalLexicon.revision ?? 1;
|
|
39
|
+
if (candidateRevision >= resolvedCurrent.revision) continue;
|
|
40
|
+
if (matchesLexicon(record, normalizedNsid, historicalLexicon)) {
|
|
41
|
+
if (options.returnLexicons === true) return {
|
|
42
|
+
revision: candidateRevision,
|
|
43
|
+
record,
|
|
44
|
+
lexicons: history ? [...history, asCandidate(currentLexicon)] : []
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
revision: candidateRevision,
|
|
48
|
+
record
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Record does not match any provided lexicon revision for ${normalizedNsid}`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Checks if a given record matches a lexicon.
|
|
56
|
+
* @param record The record to check against the lexicon.
|
|
57
|
+
* @param nsid The NSID of the lexicon.
|
|
58
|
+
* @param lexicon The lexicon record.
|
|
59
|
+
* @returns True of the record is valid, false if not.
|
|
60
|
+
*/
|
|
61
|
+
function matchesLexicon(record, nsid, lexicon) {
|
|
62
|
+
if (!hasAllRequiredFields(record, lexicon)) return false;
|
|
63
|
+
const lexicons = new Lexicons([structuredClone(lexicon)]);
|
|
64
|
+
const candidateRecord = hydrateBlobs(record);
|
|
65
|
+
try {
|
|
66
|
+
lexicons.assertValidRecord(nsid, candidateRecord);
|
|
67
|
+
return true;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof ValidationError) return false;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Checks whether a record contains all fields required by a lexicon.
|
|
75
|
+
* @param record The record to validate.
|
|
76
|
+
* @param lexicon The lexicon to validate the record against.
|
|
77
|
+
* @returns True if the record has all required fields, false if not.
|
|
78
|
+
*/
|
|
79
|
+
function hasAllRequiredFields(record, lexicon) {
|
|
80
|
+
const mainDef = lexicon.defs.main;
|
|
81
|
+
if (!mainDef || mainDef.type !== "record") return false;
|
|
82
|
+
return (mainDef.record.required ?? []).every((key) => Object.hasOwn(record, key));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Converts the JSON blob values of a record to BlobRefs.
|
|
86
|
+
* @param obj The JSON record
|
|
87
|
+
* @returns The record, with all blob references as classes instead of JSON.
|
|
88
|
+
*/
|
|
89
|
+
function hydrateBlobs(obj) {
|
|
90
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
91
|
+
if (Array.isArray(obj)) return obj.map(hydrateBlobs);
|
|
92
|
+
if (obj.ref?.$link && typeof obj.mimeType === "string") return new BlobRef(obj.ref.$link, obj.mimeType, obj.size, obj.original);
|
|
93
|
+
const entries = Object.entries(obj).map(([key, value]) => [key, hydrateBlobs(value)]);
|
|
94
|
+
return Object.fromEntries(entries);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Wraps a lexicon to match the `LexiconHistoryCandidate` type.
|
|
98
|
+
* @param currentLexicon The lexicon to wrap.
|
|
99
|
+
* @returns The wrapped lexicon, with a CID of `current`.
|
|
100
|
+
*/
|
|
101
|
+
function asCandidate(currentLexicon) {
|
|
102
|
+
return {
|
|
103
|
+
cid: "current",
|
|
104
|
+
lexicon: currentLexicon,
|
|
105
|
+
revision: currentLexicon.revision
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/util/figureOutSensibleDefault.ts
|
|
110
|
+
/**
|
|
111
|
+
* Tries to figure out a sensible default value based on the property's type.
|
|
112
|
+
*
|
|
113
|
+
* Properties with type boolean, integer, and string keep their `default` declaration as the default.
|
|
114
|
+
* Arrays are instanciated as empty.
|
|
115
|
+
*
|
|
116
|
+
* @param property The property to figure out the default for.
|
|
117
|
+
* @returns The new default value, if any.
|
|
118
|
+
*/
|
|
119
|
+
function figureOutSensibleDefault(property) {
|
|
120
|
+
let newDefault;
|
|
121
|
+
if ([
|
|
122
|
+
"boolean",
|
|
123
|
+
"integer",
|
|
124
|
+
"string"
|
|
125
|
+
].includes(property.type)) newDefault = property.default;
|
|
126
|
+
else if (property.type === "array") newDefault = [];
|
|
127
|
+
return newDefault;
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/util/isValueAllowedByProperty.ts
|
|
131
|
+
/**
|
|
132
|
+
* Checks if a value is allowed by a property and its constraints.
|
|
133
|
+
* @param value The value to validate.
|
|
134
|
+
* @param property The property the value should be used with.
|
|
135
|
+
* @returns True if the value is compatible with the property, false if not.
|
|
136
|
+
*/
|
|
137
|
+
function isValueAllowedByProperty(value, property) {
|
|
138
|
+
const constantValue = property.const;
|
|
139
|
+
if (constantValue !== void 0) return value === constantValue;
|
|
140
|
+
switch (property.type) {
|
|
141
|
+
case "boolean": return typeof value === "boolean";
|
|
142
|
+
case "integer": {
|
|
143
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return false;
|
|
144
|
+
const integerProperty = property;
|
|
145
|
+
if (integerProperty.minimum !== void 0 && value < integerProperty.minimum) return false;
|
|
146
|
+
if (integerProperty.maximum !== void 0 && value > integerProperty.maximum) return false;
|
|
147
|
+
if (integerProperty.enum && !integerProperty.enum.includes(value)) return false;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
case "string": {
|
|
151
|
+
if (typeof value !== "string") return false;
|
|
152
|
+
const stringProperty = property;
|
|
153
|
+
if (stringProperty.minLength !== void 0 && value.length < stringProperty.minLength) return false;
|
|
154
|
+
if (stringProperty.maxLength !== void 0 && value.length > stringProperty.maxLength) return false;
|
|
155
|
+
if (stringProperty.enum && !stringProperty.enum.includes(value)) return false;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
case "array": {
|
|
159
|
+
if (!Array.isArray(value)) return false;
|
|
160
|
+
const arrayProperty = property;
|
|
161
|
+
if (arrayProperty.minLength !== void 0 && value.length < arrayProperty.minLength) return false;
|
|
162
|
+
if (arrayProperty.maxLength !== void 0 && value.length > arrayProperty.maxLength) return false;
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
default: return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/util/isDomainContained.ts
|
|
170
|
+
/**
|
|
171
|
+
* Checks whether a list of given values is allowed by both the current and future property.
|
|
172
|
+
* @param fromProperty The current property.
|
|
173
|
+
* @param toProperty The property to convert to.
|
|
174
|
+
* @param domain The values that might be converted.
|
|
175
|
+
* @returns True if the type can be used with the new property, false if not.
|
|
176
|
+
*/
|
|
177
|
+
function isTypeDomainContained(fromProperty, toProperty, domain) {
|
|
178
|
+
for (const sampleValue of domain) if (isValueAllowedByProperty(sampleValue, fromProperty) && !isValueAllowedByProperty(sampleValue, toProperty)) return false;
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Checks whether a string and it's constraints can be accomodated by both the current and future properties.
|
|
183
|
+
* @param fromProperty The current property.
|
|
184
|
+
* @param toProperty The property to convert to.
|
|
185
|
+
* @returns True if the string can be used with the new property, false if not.
|
|
186
|
+
*/
|
|
187
|
+
function isStringDomainContained(fromProperty, toProperty) {
|
|
188
|
+
const fromString = fromProperty;
|
|
189
|
+
const toStringProperty = toProperty;
|
|
190
|
+
if (fromString.enum && toStringProperty.enum) return fromString.enum.every((value) => toStringProperty.enum.includes(value));
|
|
191
|
+
if (fromString.enum) return fromString.enum.every((value) => isValueAllowedByProperty(value, toProperty));
|
|
192
|
+
if (toStringProperty.enum) return false;
|
|
193
|
+
if (fromString.format !== void 0 && toStringProperty.format !== void 0 && fromString.format !== toStringProperty.format) return false;
|
|
194
|
+
if (fromString.format === void 0 && toStringProperty.format !== void 0) return false;
|
|
195
|
+
if (fromString.minLength === void 0 && toStringProperty.minLength !== void 0) return false;
|
|
196
|
+
if (fromString.maxLength === void 0 && toStringProperty.maxLength !== void 0) return false;
|
|
197
|
+
const oldMin = fromString.minLength ?? 0;
|
|
198
|
+
const oldMax = fromString.maxLength ?? Number.POSITIVE_INFINITY;
|
|
199
|
+
const newMin = toStringProperty.minLength ?? 0;
|
|
200
|
+
const newMax = toStringProperty.maxLength ?? Number.POSITIVE_INFINITY;
|
|
201
|
+
return newMin <= oldMin && oldMax <= newMax;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Checks whether an integer and it's constraints can be accomodated by both the current and future properties.
|
|
205
|
+
* @param fromProperty The current property.
|
|
206
|
+
* @param toProperty The property to convert to.
|
|
207
|
+
* @returns True if the integer value can be used with the new property, false if not.
|
|
208
|
+
*/
|
|
209
|
+
function isIntegerDomainContained(fromProperty, toProperty) {
|
|
210
|
+
const fromInteger = fromProperty;
|
|
211
|
+
const toInteger = toProperty;
|
|
212
|
+
if (fromInteger.enum && toInteger.enum) return fromInteger.enum.every((value) => toInteger.enum.includes(value));
|
|
213
|
+
if (fromInteger.enum) return fromInteger.enum.every((value) => isValueAllowedByProperty(value, toProperty));
|
|
214
|
+
if (toInteger.enum) return false;
|
|
215
|
+
if (fromInteger.minimum === void 0 && toInteger.minimum !== void 0) return false;
|
|
216
|
+
if (fromInteger.maximum === void 0 && toInteger.maximum !== void 0) return false;
|
|
217
|
+
const oldMin = fromInteger.minimum ?? Number.NEGATIVE_INFINITY;
|
|
218
|
+
const oldMax = fromInteger.maximum ?? Number.POSITIVE_INFINITY;
|
|
219
|
+
const newMin = toInteger.minimum ?? Number.NEGATIVE_INFINITY;
|
|
220
|
+
const newMax = toInteger.maximum ?? Number.POSITIVE_INFINITY;
|
|
221
|
+
return newMin <= oldMin && oldMax <= newMax;
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
224
|
+
//#region src/util/detectKeyDifferences.ts
|
|
225
|
+
/**
|
|
226
|
+
* Attempts to detect which keys differ between two lexicons, and in which way.
|
|
227
|
+
*
|
|
228
|
+
* Keys will be separated into one of five categories:
|
|
229
|
+
*
|
|
230
|
+
* - unchanged
|
|
231
|
+
* - converted
|
|
232
|
+
* - renamed
|
|
233
|
+
* - dropped
|
|
234
|
+
* - created
|
|
235
|
+
*
|
|
236
|
+
* @param lex1 The previous lexicon that the conversion is attempted from.
|
|
237
|
+
* @param lex2 The lexicon the data should be converted to.
|
|
238
|
+
* @returns The differences between the lexicons.
|
|
239
|
+
*/
|
|
240
|
+
function detectKeyDifferences(lex1, lex2) {
|
|
241
|
+
const processedFromLex1 = [];
|
|
242
|
+
const processedFromLex2 = [];
|
|
243
|
+
const unchanged = [];
|
|
244
|
+
const converted = [];
|
|
245
|
+
const renamed = [];
|
|
246
|
+
const dropped = [];
|
|
247
|
+
const created = [];
|
|
248
|
+
const lex1Properties = Object.keys(lex1.record.properties);
|
|
249
|
+
const lex2Properties = Object.keys(lex2.record.properties);
|
|
250
|
+
for (const key of lex1Properties) {
|
|
251
|
+
const sameKeyName = lex2Properties.find((x) => x === key);
|
|
252
|
+
if (sameKeyName) {
|
|
253
|
+
processedFromLex1.push(sameKeyName);
|
|
254
|
+
processedFromLex2.push(sameKeyName);
|
|
255
|
+
const oldProperty = lex1.record.properties[key];
|
|
256
|
+
const newProperty = lex2.record.properties[sameKeyName];
|
|
257
|
+
if (canKeepExistingValue(oldProperty, newProperty)) {
|
|
258
|
+
unchanged.push(key);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (canAttemptConversion(oldProperty, newProperty)) {
|
|
262
|
+
converted.push([
|
|
263
|
+
key,
|
|
264
|
+
oldProperty,
|
|
265
|
+
newProperty
|
|
266
|
+
]);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
dropped.push(key);
|
|
270
|
+
created.push([key, figureOutSensibleDefault(newProperty)]);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const lex1Entries = Object.entries(lex1.record.properties);
|
|
274
|
+
const lex2Entries = Object.entries(lex2.record.properties);
|
|
275
|
+
const unprocessedLex1Keys = lex1Entries.filter(([x]) => !processedFromLex1.some((y) => y === x));
|
|
276
|
+
for (const [lex1key, lex1val] of unprocessedLex1Keys) {
|
|
277
|
+
const unprocessedLex2Keys = lex2Entries.filter(([x]) => !processedFromLex2.some((y) => y === x));
|
|
278
|
+
for (const [lex2key, lex2val] of unprocessedLex2Keys) {
|
|
279
|
+
if (!(getPropertySignature(lex1val) === getPropertySignature(lex2val))) continue;
|
|
280
|
+
if (unprocessedLex1Keys.filter(([, maybeSame]) => getPropertySignature(maybeSame) === getPropertySignature(lex2val)).length === 1) {
|
|
281
|
+
renamed.push([lex1key, lex2key]);
|
|
282
|
+
processedFromLex1.push(lex1key);
|
|
283
|
+
processedFromLex2.push(lex2key);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const remainingLex1Keys = lex1Properties.filter((x) => !processedFromLex1.some((y) => y === x));
|
|
289
|
+
const remainingLex2Keys = lex2Properties.filter((x) => !processedFromLex2.some((y) => y === x));
|
|
290
|
+
for (const key of remainingLex1Keys) dropped.push(key);
|
|
291
|
+
for (const key of remainingLex2Keys) created.push([key, figureOutSensibleDefault(lex2.record.properties[key])]);
|
|
292
|
+
return {
|
|
293
|
+
unchanged,
|
|
294
|
+
converted,
|
|
295
|
+
renamed,
|
|
296
|
+
dropped,
|
|
297
|
+
created
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Checks whether a given property can reasonably be converted to another type.
|
|
302
|
+
*
|
|
303
|
+
* The following conversions are considered sensible:
|
|
304
|
+
*
|
|
305
|
+
* - boolean → integer
|
|
306
|
+
* - boolean → string
|
|
307
|
+
* - integer → boolean
|
|
308
|
+
* - integer → string
|
|
309
|
+
* - string → boolean
|
|
310
|
+
* - string → integer
|
|
311
|
+
*
|
|
312
|
+
* @param fromProperty The property to be converted from.
|
|
313
|
+
* @param toProperty The property to be converted to.
|
|
314
|
+
* @returns Whether the conversion can be done.
|
|
315
|
+
*/
|
|
316
|
+
function canAttemptConversion(fromProperty, toProperty) {
|
|
317
|
+
if (fromProperty.type === toProperty.type) return false;
|
|
318
|
+
return fromProperty.type === "boolean" && toProperty.type === "integer" || fromProperty.type === "boolean" && toProperty.type === "string" || fromProperty.type === "integer" && toProperty.type === "boolean" || fromProperty.type === "integer" && toProperty.type === "string" || fromProperty.type === "string" && toProperty.type === "boolean" || fromProperty.type === "string" && toProperty.type === "integer";
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Detects whether the value of a given property can be kept when converting to a different type.
|
|
322
|
+
* @param fromProperty The property that will be converted from.
|
|
323
|
+
* @param toProperty The property that will be converted to.
|
|
324
|
+
* @returns Whether the conversion can be done while preserving the value.
|
|
325
|
+
*/
|
|
326
|
+
function canKeepExistingValue(fromProperty, toProperty) {
|
|
327
|
+
if (fromProperty.type !== toProperty.type) return false;
|
|
328
|
+
if (getPropertySignature(fromProperty) === getPropertySignature(toProperty)) return true;
|
|
329
|
+
switch (fromProperty.type) {
|
|
330
|
+
case "boolean": return isTypeDomainContained(fromProperty, toProperty, [false, true]);
|
|
331
|
+
case "integer": return isIntegerDomainContained(fromProperty, toProperty);
|
|
332
|
+
case "string": return isStringDomainContained(fromProperty, toProperty);
|
|
333
|
+
case "array": {
|
|
334
|
+
const fromArray = fromProperty;
|
|
335
|
+
const toArray = toProperty;
|
|
336
|
+
const oldMin = fromArray.minLength ?? 0;
|
|
337
|
+
const oldMax = fromArray.maxLength;
|
|
338
|
+
const newMin = toArray.minLength ?? 0;
|
|
339
|
+
const newMax = toArray.maxLength;
|
|
340
|
+
return newMin <= oldMin && (newMax === void 0 || oldMax !== void 0 && oldMax <= newMax);
|
|
341
|
+
}
|
|
342
|
+
default: return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Returns the stringified version of a normalized property.
|
|
347
|
+
* @param property The property to get the signature for.
|
|
348
|
+
* @returns The signature of the property.
|
|
349
|
+
*/
|
|
350
|
+
function getPropertySignature(property) {
|
|
351
|
+
return JSON.stringify(normalizeForComparison(property));
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Normalizes a given value so it can be compared to another.
|
|
355
|
+
* @param value The value to normalize.
|
|
356
|
+
* @returns The normalized value.
|
|
357
|
+
*/
|
|
358
|
+
function normalizeForComparison(value) {
|
|
359
|
+
if (Array.isArray(value)) {
|
|
360
|
+
const normalizedArray = value.map((item) => normalizeForComparison(item));
|
|
361
|
+
if (normalizedArray.every((item) => [
|
|
362
|
+
"string",
|
|
363
|
+
"number",
|
|
364
|
+
"boolean"
|
|
365
|
+
].includes(typeof item))) return [...normalizedArray].sort((a, b) => String(a).localeCompare(String(b)));
|
|
366
|
+
return normalizedArray;
|
|
367
|
+
}
|
|
368
|
+
if (value && typeof value === "object") {
|
|
369
|
+
const normalizedEntries = Object.entries(value).filter(([key]) => key !== "description").sort(([a], [b]) => a.localeCompare(b)).map(([key, entryValue]) => [key, normalizeForComparison(entryValue)]);
|
|
370
|
+
return Object.fromEntries(normalizedEntries);
|
|
371
|
+
}
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/shiftRecord.ts
|
|
376
|
+
const CONVERSION_FAILED = Symbol("conversion-failed");
|
|
377
|
+
/**
|
|
378
|
+
* Shifts a given record along an upgrade path.
|
|
379
|
+
*
|
|
380
|
+
* @param record The record to shift.
|
|
381
|
+
* @param upgradePath An array of lexicon history candidates in correct order of revisions.
|
|
382
|
+
* @returns The shifted record.
|
|
383
|
+
*/
|
|
384
|
+
function shiftRecord(record, upgradePath) {
|
|
385
|
+
const upgradeStepCount = upgradePath.length - 1;
|
|
386
|
+
const recordTemplate = structuredClone(record);
|
|
387
|
+
for (let i = 0; i < upgradeStepCount; i++) {
|
|
388
|
+
const currentLexicon = upgradePath[i];
|
|
389
|
+
const nextLexicon = upgradePath[i + 1];
|
|
390
|
+
const { created, dropped, renamed, converted } = detectKeyDifferences(ensureLexRecord(currentLexicon.lexicon), ensureLexRecord(nextLexicon.lexicon));
|
|
391
|
+
for (const [key, fromProperty, toProperty] of converted) {
|
|
392
|
+
const convertedValue = tryConvertPropertyValue(recordTemplate[key], fromProperty, toProperty);
|
|
393
|
+
recordTemplate[key] = convertedValue === CONVERSION_FAILED ? figureOutSensibleDefault(toProperty) : convertedValue;
|
|
394
|
+
}
|
|
395
|
+
for (const key of dropped) delete recordTemplate[key];
|
|
396
|
+
for (const [oldKey, newKey] of renamed) {
|
|
397
|
+
recordTemplate[newKey] = structuredClone(recordTemplate[oldKey]);
|
|
398
|
+
delete recordTemplate[oldKey];
|
|
399
|
+
}
|
|
400
|
+
for (const [key, defaultVal] of created) recordTemplate[key] = defaultVal;
|
|
401
|
+
}
|
|
402
|
+
return recordTemplate;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Attempts to convert a given value from one property to another.
|
|
406
|
+
* @param value The value to convert.
|
|
407
|
+
* @param fromProperty The property to convert from.
|
|
408
|
+
* @param toProperty The property to convert to.
|
|
409
|
+
* @returns The converted value.
|
|
410
|
+
*/
|
|
411
|
+
function tryConvertPropertyValue(value, fromProperty, toProperty) {
|
|
412
|
+
if (isValueAllowedByProperty(value, toProperty)) return value;
|
|
413
|
+
let convertedValue = CONVERSION_FAILED;
|
|
414
|
+
if (fromProperty.type === "boolean" && toProperty.type === "integer" && typeof value === "boolean") convertedValue = value ? 1 : 0;
|
|
415
|
+
else if (fromProperty.type === "boolean" && toProperty.type === "string" && typeof value === "boolean") convertedValue = value ? "true" : "false";
|
|
416
|
+
else if (fromProperty.type === "integer" && toProperty.type === "boolean" && typeof value === "number") convertedValue = value !== 0;
|
|
417
|
+
else if (fromProperty.type === "integer" && toProperty.type === "string" && typeof value === "number") convertedValue = value.toString();
|
|
418
|
+
else if (fromProperty.type === "string" && toProperty.type === "boolean" && typeof value === "string") {
|
|
419
|
+
const normalized = value.trim().toLowerCase();
|
|
420
|
+
if (normalized === "true") convertedValue = true;
|
|
421
|
+
else if (normalized === "false") convertedValue = false;
|
|
422
|
+
} else if (fromProperty.type === "string" && toProperty.type === "integer" && typeof value === "string") {
|
|
423
|
+
const trimmed = value.trim();
|
|
424
|
+
if (/^-?\d+$/.test(trimmed)) convertedValue = Number.parseInt(trimmed, 10);
|
|
425
|
+
}
|
|
426
|
+
if (convertedValue === CONVERSION_FAILED) return CONVERSION_FAILED;
|
|
427
|
+
return isValueAllowedByProperty(convertedValue, toProperty) ? convertedValue : CONVERSION_FAILED;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Ensures a given lexicon is a record lexicon.
|
|
431
|
+
* @param lexicon The lexicon to check.
|
|
432
|
+
* @returns A typed record lexicon.
|
|
433
|
+
*/
|
|
434
|
+
function ensureLexRecord(lexicon) {
|
|
435
|
+
const typedLexicon = lexicon;
|
|
436
|
+
if (!typedLexicon.defs?.main) throw new Error("Provided lexicon does not have a main definition. Unable to process.");
|
|
437
|
+
if (typedLexicon.defs.main.type !== "record") throw new Error("Provided lexicon's main definition is not a record. Unable to process.");
|
|
438
|
+
return typedLexicon.defs.main;
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region src/shift.ts
|
|
442
|
+
/**
|
|
443
|
+
* Shifts a record from its current lexicon revision to another one, by either upshifting or downshifting.
|
|
444
|
+
*
|
|
445
|
+
* @param record The record to shift.
|
|
446
|
+
* @param newRevision The revision to shift to.
|
|
447
|
+
* @param options The options for this function.
|
|
448
|
+
*/
|
|
449
|
+
async function shift(record, newRevision, options = {}) {
|
|
450
|
+
const { revision: oldRevision, lexicons } = await identify(record, {
|
|
451
|
+
...options,
|
|
452
|
+
returnLexicons: true
|
|
453
|
+
});
|
|
454
|
+
if (oldRevision === newRevision) return {
|
|
455
|
+
oldRevision,
|
|
456
|
+
newRevision,
|
|
457
|
+
record
|
|
458
|
+
};
|
|
459
|
+
const lowerRevision = newRevision > oldRevision ? oldRevision : newRevision;
|
|
460
|
+
const higherRevision = newRevision === lowerRevision ? oldRevision : newRevision;
|
|
461
|
+
return {
|
|
462
|
+
oldRevision,
|
|
463
|
+
newRevision,
|
|
464
|
+
record: shiftRecord(record, lexicons.filter((x) => x.revision >= lowerRevision && x.revision <= higherRevision).sort((a, b) => {
|
|
465
|
+
if (newRevision > oldRevision) return (a.revision ?? 0) - (b.revision ?? 0);
|
|
466
|
+
return (b.revision ?? 0) - (a.revision ?? 0);
|
|
467
|
+
}))
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
//#endregion
|
|
471
|
+
export { identify, shift };
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lexshift",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Identify and migrate AT Protocol records across lexicon revisions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.mts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.mts",
|
|
11
|
+
"import": "./dist/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"keywords": [
|
|
20
|
+
"atproto",
|
|
21
|
+
"at protocol",
|
|
22
|
+
"lexicon",
|
|
23
|
+
"schema",
|
|
24
|
+
"migration"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/louisescher/lexshift.git",
|
|
29
|
+
"directory": "packages/lexshift"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/louisescher/lexshift/tree/main/packages/lexshift",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/louisescher/lexshift/issues"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsdown",
|
|
37
|
+
"check-types": "tsc -b",
|
|
38
|
+
"prepack": "pnpm run build"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@atproto/lex-resolver": "^0.0.23",
|
|
45
|
+
"@atproto/lexicon": "^0.6.2"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"tsdown": "^0.21.9",
|
|
49
|
+
"typescript": "^5.9.3"
|
|
50
|
+
}
|
|
51
|
+
}
|