@aelionsdk/transaction 0.1.0-beta.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.
@@ -0,0 +1,222 @@
1
+ import { AelionError } from '@aelionsdk/core';
2
+ import { canonicalClone, } from '@aelionsdk/project-schema';
3
+ import { collectAffectedRanges } from './affected-ranges.js';
4
+ import { applyOperations } from './operations.js';
5
+ function deepFreeze(value) {
6
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
7
+ Object.freeze(value);
8
+ Object.values(value).forEach(entry => deepFreeze(entry));
9
+ }
10
+ return value;
11
+ }
12
+ function transactionError(code, message) {
13
+ return new AelionError([
14
+ {
15
+ code,
16
+ severity: 'error',
17
+ message,
18
+ recoverable: true,
19
+ },
20
+ ]);
21
+ }
22
+ function createDraft(project, operations) {
23
+ const draft = { ...project };
24
+ const collections = new Set(operations.map(operation => operation.collection));
25
+ for (const collection of collections) {
26
+ const source = project[collection];
27
+ const cloned = { ...source };
28
+ const entityIds = new Set(operations
29
+ .filter(operation => operation.collection === collection)
30
+ .map(operation => operation.id));
31
+ for (const id of entityIds) {
32
+ const entity = source[id];
33
+ if (entity !== undefined)
34
+ cloned[id] = canonicalClone(entity);
35
+ }
36
+ Reflect.set(draft, collection, cloned);
37
+ }
38
+ return draft;
39
+ }
40
+ let changeSetSequence = 0;
41
+ export const TRANSACTION_MAX_OPERATIONS = 16_384;
42
+ function createChangeSetId() {
43
+ changeSetSequence += 1;
44
+ return `chg_${Date.now().toString(36)}_${changeSetSequence.toString(36)}`;
45
+ }
46
+ export class TransactionBuilder {
47
+ #operations = [];
48
+ get operations() {
49
+ return this.#operations;
50
+ }
51
+ /**
52
+ * Appends an already validated operation sequence to this transaction.
53
+ *
54
+ * This is primarily used by history/replay adapters. Operations are cloned so
55
+ * callers cannot mutate a transaction after it has been committed.
56
+ */
57
+ appendOperations(operations) {
58
+ if (this.#operations.length + operations.length > TRANSACTION_MAX_OPERATIONS) {
59
+ throw transactionError('TRANSACTION_OPERATION_LIMIT_EXCEEDED', `A transaction cannot contain more than ${TRANSACTION_MAX_OPERATIONS.toString()} operations`);
60
+ }
61
+ this.#operations.push(...operations.map(operation => structuredClone(operation)));
62
+ }
63
+ createEntity(collection, id, value) {
64
+ this.#push({ op: 'createEntity', collection, id, value });
65
+ }
66
+ deleteEntity(collection, id) {
67
+ this.#push({ op: 'deleteEntity', collection, id });
68
+ }
69
+ setField(collection, id, path, value) {
70
+ this.#push({ op: 'setField', collection, id, path, value });
71
+ }
72
+ removeField(collection, id, path) {
73
+ this.#push({ op: 'removeField', collection, id, path });
74
+ }
75
+ listInsert(collection, id, path, valueId, beforeId) {
76
+ this.#push({
77
+ op: 'listInsert',
78
+ collection,
79
+ id,
80
+ path,
81
+ valueId,
82
+ ...(beforeId === undefined ? {} : { beforeId }),
83
+ });
84
+ }
85
+ listRemove(collection, id, path, valueId) {
86
+ this.#push({ op: 'listRemove', collection, id, path, valueId });
87
+ }
88
+ listMove(collection, id, path, valueId, beforeId) {
89
+ this.#push({
90
+ op: 'listMove',
91
+ collection,
92
+ id,
93
+ path,
94
+ valueId,
95
+ ...(beforeId === undefined ? {} : { beforeId }),
96
+ });
97
+ }
98
+ #push(operation) {
99
+ if (this.#operations.length >= TRANSACTION_MAX_OPERATIONS) {
100
+ throw transactionError('TRANSACTION_OPERATION_LIMIT_EXCEEDED', `A transaction cannot contain more than ${TRANSACTION_MAX_OPERATIONS.toString()} operations`);
101
+ }
102
+ this.#operations.push(operation);
103
+ }
104
+ }
105
+ export class TransactionEngine {
106
+ #validate;
107
+ #prepareCommit;
108
+ #listeners = new Set();
109
+ #project;
110
+ #revision = 0n;
111
+ #committing = false;
112
+ #reentrantAttempted = false;
113
+ constructor(project, validate, options = {}) {
114
+ const cloned = canonicalClone(project);
115
+ const result = validate(cloned);
116
+ if (!result.ok)
117
+ throw new AelionError(result.diagnostics);
118
+ this.#project = deepFreeze(cloned);
119
+ this.#validate = validate;
120
+ this.#prepareCommit = options.prepareCommit;
121
+ }
122
+ get revision() {
123
+ return this.#revision;
124
+ }
125
+ getSnapshot() {
126
+ return this.#project;
127
+ }
128
+ subscribe(listener) {
129
+ this.#listeners.add(listener);
130
+ return () => this.#listeners.delete(listener);
131
+ }
132
+ edit(options, callback) {
133
+ if (this.#committing) {
134
+ this.#reentrantAttempted = true;
135
+ throw transactionError('TRANSACTION_REENTRANT', 'A transaction cannot start while another transaction is being committed');
136
+ }
137
+ this.#committing = true;
138
+ this.#reentrantAttempted = false;
139
+ try {
140
+ const baseRevision = options.baseRevision ?? this.#revision;
141
+ if (baseRevision !== this.#revision) {
142
+ throw transactionError('REVISION_CONFLICT', `Expected revision ${baseRevision}, current revision is ${this.#revision}`);
143
+ }
144
+ const before = this.#project;
145
+ const transaction = new TransactionBuilder();
146
+ callback(transaction);
147
+ this.#assertNoReentrantAttempt();
148
+ if (transaction.operations.length === 0) {
149
+ throw transactionError('TRANSACTION_EMPTY', 'A transaction must contain an operation');
150
+ }
151
+ const draft = createDraft(before, transaction.operations);
152
+ const inverse = applyOperations(draft, transaction.operations);
153
+ const validation = this.#validate(draft);
154
+ if (!validation.ok)
155
+ throw new AelionError(validation.diagnostics);
156
+ this.#assertNoReentrantAttempt();
157
+ this.#assertBaseUnchanged(baseRevision, before);
158
+ const committedRevision = baseRevision + 1n;
159
+ const affectedEntityIds = [
160
+ ...new Set(transaction.operations.map(operation => operation.id)),
161
+ ].sort();
162
+ const changeSet = {
163
+ id: createChangeSetId(),
164
+ baseRevision,
165
+ committedRevision,
166
+ ...(options.label === undefined ? {} : { label: options.label }),
167
+ operations: transaction.operations.map(operation => structuredClone(operation)),
168
+ affectedEntityIds,
169
+ affectedRanges: collectAffectedRanges(before, draft, transaction.operations),
170
+ };
171
+ const snapshot = deepFreeze(draft);
172
+ const commit = {
173
+ revision: committedRevision,
174
+ snapshot,
175
+ changeSet,
176
+ inverse,
177
+ };
178
+ const prepared = this.#prepareCommit?.(commit);
179
+ this.#assertNoReentrantAttempt();
180
+ this.#assertBaseUnchanged(baseRevision, before);
181
+ prepared?.publish();
182
+ this.#assertNoReentrantAttempt();
183
+ this.#assertBaseUnchanged(baseRevision, before);
184
+ this.#project = snapshot;
185
+ this.#revision = committedRevision;
186
+ // Change listeners are post-commit observers. One observer must not make
187
+ // a successful commit appear to fail or extend this dispatch by adding
188
+ // listeners while it is in progress.
189
+ for (const listener of [...this.#listeners]) {
190
+ try {
191
+ listener(commit);
192
+ }
193
+ catch {
194
+ // Observer failures do not roll back an already-published commit.
195
+ }
196
+ }
197
+ return commit;
198
+ }
199
+ finally {
200
+ this.#committing = false;
201
+ this.#reentrantAttempted = false;
202
+ }
203
+ }
204
+ applyChangeSet(changeSet) {
205
+ return this.edit({
206
+ ...(changeSet.label === undefined ? {} : { label: changeSet.label }),
207
+ baseRevision: changeSet.baseRevision,
208
+ }, transaction => {
209
+ transaction.appendOperations(changeSet.operations);
210
+ });
211
+ }
212
+ #assertBaseUnchanged(baseRevision, before) {
213
+ if (this.#revision === baseRevision && this.#project === before)
214
+ return;
215
+ throw transactionError('REVISION_CONFLICT', `Expected revision ${baseRevision}, current revision is ${this.#revision}`);
216
+ }
217
+ #assertNoReentrantAttempt() {
218
+ if (!this.#reentrantAttempted)
219
+ return;
220
+ throw transactionError('TRANSACTION_REENTRANT', 'A nested transaction was attempted while this transaction was being committed');
221
+ }
222
+ }
@@ -0,0 +1,91 @@
1
+ import type { Diagnostic, JsonObject, JsonValue } from '@aelionsdk/core';
2
+ import type { AelionProject, CollectionName, EntityId, TimeRange } from '@aelionsdk/project-schema';
3
+ export type AtomicOperation = {
4
+ readonly op: 'createEntity';
5
+ readonly collection: CollectionName;
6
+ readonly id: EntityId;
7
+ readonly value: JsonObject;
8
+ } | {
9
+ readonly op: 'deleteEntity';
10
+ readonly collection: CollectionName;
11
+ readonly id: EntityId;
12
+ } | {
13
+ readonly op: 'setField';
14
+ readonly collection: CollectionName;
15
+ readonly id: EntityId;
16
+ readonly path: readonly string[];
17
+ readonly value: JsonValue;
18
+ } | {
19
+ readonly op: 'removeField';
20
+ readonly collection: CollectionName;
21
+ readonly id: EntityId;
22
+ readonly path: readonly string[];
23
+ } | {
24
+ readonly op: 'listInsert';
25
+ readonly collection: CollectionName;
26
+ readonly id: EntityId;
27
+ readonly path: readonly string[];
28
+ readonly beforeId?: EntityId;
29
+ readonly valueId: EntityId;
30
+ } | {
31
+ readonly op: 'listRemove';
32
+ readonly collection: CollectionName;
33
+ readonly id: EntityId;
34
+ readonly path: readonly string[];
35
+ readonly valueId: EntityId;
36
+ } | {
37
+ readonly op: 'listMove';
38
+ readonly collection: CollectionName;
39
+ readonly id: EntityId;
40
+ readonly path: readonly string[];
41
+ readonly beforeId?: EntityId;
42
+ readonly valueId: EntityId;
43
+ };
44
+ export interface AffectedRange extends TimeRange {
45
+ readonly sequenceId: EntityId;
46
+ }
47
+ export interface ChangeSet {
48
+ readonly id: string;
49
+ readonly baseRevision: bigint;
50
+ readonly committedRevision: bigint;
51
+ readonly label?: string;
52
+ readonly operations: readonly AtomicOperation[];
53
+ readonly affectedEntityIds: readonly EntityId[];
54
+ readonly affectedRanges: readonly AffectedRange[];
55
+ }
56
+ export interface TransactionCommit {
57
+ readonly revision: bigint;
58
+ readonly snapshot: Readonly<AelionProject>;
59
+ readonly changeSet: ChangeSet;
60
+ readonly inverse: readonly AtomicOperation[];
61
+ }
62
+ export interface EditOptions {
63
+ readonly label?: string;
64
+ readonly baseRevision?: bigint;
65
+ /** Consecutive edits with the same key are stored as one undo/redo entry. */
66
+ readonly historyGroup?: string;
67
+ }
68
+ export interface TransactionValidationResult {
69
+ readonly ok: boolean;
70
+ readonly diagnostics: readonly Diagnostic[];
71
+ }
72
+ export type ProjectValidation = (project: unknown) => TransactionValidationResult;
73
+ export type ProjectChangeListener = (commit: TransactionCommit) => void;
74
+ /**
75
+ * A prepared, synchronous side effect that is published immediately before the
76
+ * Project snapshot and revision become visible. Implementations must not call
77
+ * consumer code; throwing prevents the TransactionEngine commit.
78
+ */
79
+ export interface PreparedTransactionCommit {
80
+ publish(): void;
81
+ }
82
+ /**
83
+ * Prepares derived state for a candidate commit without mutating the active
84
+ * Project. This is used by hosts such as AelionSession to compile Render IR as
85
+ * part of the same atomic commit boundary.
86
+ */
87
+ export type TransactionCommitPreparer = (commit: TransactionCommit) => PreparedTransactionCommit | undefined;
88
+ export interface TransactionEngineOptions {
89
+ readonly prepareCommit?: TransactionCommitPreparer;
90
+ }
91
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@aelionsdk/transaction",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Atomic project transactions, inverses and change sets for AelionSDK",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/FoyonaCZY/AelionSDK.git",
9
+ "directory": "packages/transaction"
10
+ },
11
+ "keywords": [
12
+ "aelion",
13
+ "timeline",
14
+ "transaction",
15
+ "undo"
16
+ ],
17
+ "sideEffects": false,
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "!dist/.tsbuildinfo"
28
+ ],
29
+ "engines": {
30
+ "node": ">=20.19"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "dependencies": {
37
+ "@aelionsdk/core": "0.1.0-beta.1",
38
+ "@aelionsdk/project-schema": "0.1.0-beta.1"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -b",
42
+ "typecheck": "tsc -b --pretty false"
43
+ }
44
+ }