diff --git a/docs/spec.md b/docs/spec.md index 3cf5bb1..7f612ac 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -364,7 +364,9 @@ Plain `Stack` methods remain unscoped and perform no permission checks — corre ## Versions -Version history is managed by the library as a side channel — apps do not manage it directly. On every `update()`, the library snapshots the previous state. +Version history is managed by the library as a side channel — apps do not manage it directly. + +**One rule, no special cases:** every mutation of a Record — content (`update`), associations (`associate`/`dissociate`), permissions (`setPermissions`), soft delete, undelete — snapshots the Record's prior full state and bumps `version`. A mutation that changes nothing (re-adding an association that's already present, removing one that isn't there, setting a deep-equal permission set, deleting an already-deleted Record) is a no-op: no bump, no snapshot. Hard delete is the one exception — it destroys the Record and its version history outright, so there's nothing to snapshot. ```ts type RecordVersion = { @@ -372,19 +374,21 @@ type RecordVersion = { content: object; updatedAt: Date; entityId?: string; // Who made this change + associations?: Association[]; // Present if the record had associations at snapshot time + permissions?: Permission[]; // Present if the record had permissions at snapshot time }; ``` **API surface:** - `stack.getVersions(recordId)` — retrieve version history -- `stack.restoreVersion(recordId, version)` — revert to a prior version +- `stack.restoreVersion(recordId, version)` — revert to a prior version. Restores `content` and `associations` when the target snapshot has them, but **never restores `permissions`** — those are owner/creator territory (see [Permissions](#permissions)), and silently reverting an ACL as a side effect of a content rollback would be a surprise nobody wants. Permissions in a snapshot are for audit and deliberate owner action, not automatic restore. **Storage per adapter:** - JSON: sibling file `{id}.versions.json` - SQLite: `versions` table -- API: server snapshots automatically on every `PATCH /records/:id`; history is read via `/records/:id/versions` +- API: server snapshots automatically on every mutating endpoint (`PATCH /records/:id`, associations, permissions, delete, undelete); history is read via `/records/:id/versions` --- @@ -707,7 +711,7 @@ PUT /records/:id/permissions — replace all permissions (empty array = ### Versions -The server snapshots a record's state automatically on every `PATCH /records/:id` — there is no client-initiated endpoint to write a version directly. +The server snapshots a record's state automatically on every mutating endpoint — content, associations, permissions, delete, undelete (see [Versions](#versions) for the full rule) — there is no client-initiated endpoint to write a version directly. ``` GET /records/:id/versions — list all versions (newest first) diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 78722c9..5e074ad 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -123,6 +123,8 @@ const parseVersion = (raw: WireVersion): RecordVersion => { updatedAt: new Date(raw.updatedAt), }; if (raw.entityId != null) v.entityId = raw.entityId; + if (raw.associations != null) v.associations = raw.associations; + if (raw.permissions != null) v.permissions = raw.permissions; return v; }; diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index 9815f32..0bff88c 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -488,6 +488,19 @@ describe('getVersion', () => { mockFetch.mockResolvedValueOnce(new Response(null, { status: 404 })); expect(await adapter.getVersion('rec-abc123', 99)).toBeNull(); }); + + test('parses associations and permissions when present', async () => { + const adapter = await openAdapter(); + const withOptionals = { + ...VERSION_RAW, + associations: [{ kind: 'tag', label: 'starred' }], + permissions: [{ access: 'public' }], + }; + mockFetch.mockResolvedValueOnce(jsonResponse(withOptionals)); + const version = await adapter.getVersion('rec-abc123', 1); + expect(version?.associations).toEqual([{ kind: 'tag', label: 'starred' }]); + expect(version?.permissions).toEqual([{ access: 'public' }]); + }); }); describe('saveVersion', () => { diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 246987f..fdbd137 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -504,46 +504,90 @@ export class Stack implements StackClient { } /** - * Add an association to a record. + * Add an association to a record. Snapshots the record's prior state and + * bumps version, same as update() — associations are covered by the same + * versioning rule as content. * If the association already exists (same kind, label, and payload), this is a no-op. */ async associate(id: string, association: Association): Promise { - return this.adapter.associate(id, association); + const existing = await this.adapter.getRecord(id); + if (!existing) { + throw new StackNotFoundError(`Record not found: "${id}"`); + } + if ((existing.associations ?? []).some((a) => associationEqual(a, association))) return; + + await this.saveVersion(existing); + await this.adapter.associate(id, association); + await this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() }); } /** - * Remove an association from a record. - * Matched by kind, label, and payload. No-op if not found. + * Remove an association from a record. Snapshots and bumps version, same + * as associate(). Matched by kind, label, and payload. No-op if not found. */ async dissociate(id: string, association: Association): Promise { - return this.adapter.dissociate(id, association); + const existing = await this.adapter.getRecord(id); + if (!existing) { + throw new StackNotFoundError(`Record not found: "${id}"`); + } + if (!(existing.associations ?? []).some((a) => associationEqual(a, association))) return; + + await this.saveVersion(existing); + await this.adapter.dissociate(id, association); + await this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() }); } /** - * Replace all permissions on a record. - * Pass an empty array to make the record private (the default). + * Replace all permissions on a record. Snapshots and bumps version, same + * as associate(). Pass an empty array to make the record private (the + * default). No-op if the new set is deep-equal to the current one. */ async setPermissions(id: string, permissions: Permission[]): Promise { const existing = await this.adapter.getRecord(id); if (!existing) { throw new StackNotFoundError(`Record not found: "${id}"`); } - await this.adapter.updateRecord(id, { permissions }); + if (permissionsEqual(existing.permissions ?? [], permissions)) return; + + await this.saveVersion(existing); + await this.adapter.updateRecord(id, { + permissions, + version: existing.version + 1, + updatedAt: new Date(), + }); } /** * Soft-delete a record (default) or hard-delete it permanently. * Soft-deleted records are excluded from queries unless includeDeleted is set. * Hard-deleted records and all their version history are permanently removed. + * + * Soft delete snapshots the record's prior state and bumps version, same + * as update(); it's a no-op if the record is already deleted. Hard delete + * destroys the record (and its version history) outright — there's + * nothing to snapshot. */ async delete(id: string, opts: DeleteRecordOptions = {}): Promise { - return this.adapter.deleteRecord(id, opts); + if (opts.hard) { + return this.adapter.deleteRecord(id, opts); + } + + const existing = await this.adapter.getRecord(id); + if (!existing) { + throw new StackNotFoundError(`Record not found: "${id}"`); + } + if (existing.deletedAt) return; + + await this.saveVersion(existing); + await this.adapter.deleteRecord(id, opts); + await this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() }); } /** * Reverse a soft delete. Idempotent — undeleting a record that isn't * deleted returns it unchanged. Hard-deleted records are gone, so this * throws StackNotFoundError for them just like any other missing record. + * Snapshots and bumps version, same as delete(). */ async undelete(id: string): Promise { const existing = await this.adapter.getRecord(id); @@ -551,7 +595,10 @@ export class Stack implements StackClient { throw new StackNotFoundError(`Record not found: "${id}"`); } if (!existing.deletedAt) return existing; - return this.adapter.undeleteRecord(id); + + await this.saveVersion(existing); + await this.adapter.undeleteRecord(id); + return this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() }); } /** @@ -577,6 +624,12 @@ export class Stack implements StackClient { /** * Restore a record to a previous version by creating a new version * with the old content. Never rewrites history. + * + * Restores associations too, when the target snapshot has them. Never + * restores permissions — those are owner/creator territory (see + * setPermissions()), and silently reverting an ACL as a side effect of a + * content rollback would be a surprise nobody wants. Permissions in a + * snapshot are for audit and deliberate owner action, not automatic restore. */ async restoreVersion(id: string, version: number): Promise { const existing = await this.adapter.getRecord(id); @@ -596,6 +649,7 @@ export class Stack implements StackClient { content: target.content, updatedAt: new Date(), version: existing.version + 1, + ...(target.associations !== undefined && { associations: target.associations }), }); } @@ -760,11 +814,45 @@ export class Stack implements StackClient { content: record.content, updatedAt: record.updatedAt, ...(record.entityId && { entityId: record.entityId }), + ...(record.associations && { associations: record.associations }), + ...(record.permissions && { permissions: record.permissions }), }; await this.adapter.saveVersion(record.id, version); } } +// ------------------------------------------------------- +// Equality helpers +// ------------------------------------------------------- + +/** + * Matches the SQLite adapter's association primary key (kind, label, + * file_id, related_id) — mimeType is not part of an attachment + * association's identity. + */ +function associationEqual(a: Association, b: Association): boolean { + if (a.kind !== b.kind || a.label !== b.label) return false; + if (a.kind === 'attachment' && b.kind === 'attachment') return a.fileId === b.fileId; + if (a.kind === 'relationship' && b.kind === 'relationship') return a.recordId === b.recordId; + return true; +} + +function permissionEqual(a: Permission, b: Permission): boolean { + if (a.access !== b.access) return false; + if (a.access === 'public') return true; + if (a.access === 'entity' && b.access === 'entity') { + return a.entityId === b.entityId && a.read === b.read && a.write === b.write; + } + if (a.access === 'group' && b.access === 'group') { + return a.groupId === b.groupId && a.read === b.read && a.write === b.write; + } + return false; +} + +function permissionsEqual(a: Permission[], b: Permission[]): boolean { + return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i])); +} + // ------------------------------------------------------- // ScopedStack // ------------------------------------------------------- diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2e92d6e..4b543bd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -92,6 +92,8 @@ export type RecordVersion = { content: Record; updatedAt: Date; entityId?: RecordId; // Who made this change + associations?: Association[]; + permissions?: Permission[]; }; // ------------------------------------------------------- diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index d7b645a..72a314d 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -405,6 +405,70 @@ describe('versions', () => { const record = await stack.create(NOTE_V1, { text: 'hello' }); await expect(stack.restoreVersion(record.id, 99)).rejects.toThrow(); }); + + test('restoreVersion restores associations captured in the snapshot', async () => { + const record = await stack.create(NOTE_V1, { text: 'original' }); + await stack.associate(record.id, { kind: 'tag', label: 'favourite' }); // v2 + await stack.update(record.id, { text: 'changed' }); // v3, snapshots v2 (assoc: [favourite]) + await stack.dissociate(record.id, { kind: 'tag', label: 'favourite' }); // v4, assoc now [] + const restored = await stack.restoreVersion(record.id, 2); // v5 + expect(restored.content).toEqual({ text: 'original' }); + expect(restored.associations).toEqual([{ kind: 'tag', label: 'favourite' }]); + }); + + test('restoreVersion never restores permissions, even when the snapshot has them', async () => { + const record = await stack.create(NOTE_V1, { text: 'original' }); + await stack.setPermissions(record.id, [{ access: 'public' }]); // v2 + await stack.update(record.id, { text: 'changed' }); // v3, snapshots v2 (permissions: [public]) + await stack.setPermissions(record.id, []); // v4, private again + const restored = await stack.restoreVersion(record.id, 2); // v5 + expect(restored.content).toEqual({ text: 'original' }); + expect(restored.permissions).toEqual([]); + }); + + test('version snapshot captures associations and permissions when present', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.associate(record.id, { kind: 'tag', label: 'x' }); // v2, snapshots v1 + await stack.setPermissions(record.id, [{ access: 'public' }]); // v3, snapshots v2 + await stack.update(record.id, { text: 'changed' }); // v4, snapshots v3 + const versions = await stack.getVersions(record.id); + const v3snap = versions.find((v) => v.version === 3); + expect(v3snap?.associations).toEqual([{ kind: 'tag', label: 'x' }]); + expect(v3snap?.permissions).toEqual([{ access: 'public' }]); + }); +}); + +// ------------------------------------------------------- +// Versioning rule — mixed mutations +// ------------------------------------------------------- + +describe('versioning rule — mixed mutations', () => { + test('version increments by exactly one per real mutation, across mixed operation types', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); // v1 + await stack.update(record.id, { text: 'v2' }); // v2 + await stack.associate(record.id, { kind: 'tag', label: 'x' }); // v3 + await stack.setPermissions(record.id, [{ access: 'public' }]); // v4 + await stack.dissociate(record.id, { kind: 'tag', label: 'x' }); // v5 + await stack.delete(record.id); // v6 + const undeleted = await stack.undelete(record.id); // v7 + + expect(undeleted.version).toBe(7); + const versionNumbers = (await stack.getVersions(record.id)) + .map((v) => v.version) + .sort((a, b) => a - b); + expect(versionNumbers).toEqual([1, 2, 3, 4, 5, 6]); + }); + + test('no-op mutations never bump version or add a snapshot', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); // v1 + await stack.associate(record.id, { kind: 'tag', label: 'x' }); // v2 + await stack.associate(record.id, { kind: 'tag', label: 'x' }); // no-op + await stack.dissociate(record.id, { kind: 'tag', label: 'gone' }); // no-op + await stack.setPermissions(record.id, []); // no-op (already private) + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(2); + expect(await stack.getVersions(record.id)).toHaveLength(1); + }); }); // ------------------------------------------------------- @@ -438,6 +502,27 @@ describe('delete', () => { await stack.delete(record.id, { hard: true }); expect(await adapter.getRecord(record.id)).toBeNull(); }); + + test('soft delete bumps version and snapshots the prior state', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.delete(record.id); + const deleted = await adapter.getRecord(record.id); + expect(deleted?.version).toBe(2); + expect(await stack.getVersions(record.id)).toHaveLength(1); + }); + + test('soft-deleting an already-deleted record is a no-op — no version bump', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.delete(record.id); + await stack.delete(record.id); + const deleted = await adapter.getRecord(record.id); + expect(deleted?.version).toBe(2); + expect(await stack.getVersions(record.id)).toHaveLength(1); + }); + + test('throws StackNotFoundError for a missing record (soft delete)', async () => { + await expect(stack.delete('nonexistent')).rejects.toThrow(StackNotFoundError); + }); }); // ------------------------------------------------------- @@ -484,6 +569,22 @@ describe('undelete', () => { const result = await stack.query({ filter: { typeId: NOTE_V1 } }); expect(result.records.find((r) => r.id === record.id)).toBeDefined(); }); + + test('bumps version and snapshots the deleted state', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); // v1 + await stack.delete(record.id); // v2 + const undeleted = await stack.undelete(record.id); // v3 + expect(undeleted.version).toBe(3); + expect(await stack.getVersions(record.id)).toHaveLength(2); + }); + + test('idempotent no-op undelete does not bump version', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.undelete(record.id); + const result = await adapter.getRecord(record.id); + expect(result?.version).toBe(1); + expect(await stack.getVersions(record.id)).toHaveLength(0); + }); }); // ------------------------------------------------------- @@ -577,6 +678,94 @@ describe('associate / dissociate', () => { const updated = await adapter.getRecord(record.id); expect(updated?.associations?.some((a) => a.label === 'favourite')).toBe(false); }); + + test('associate bumps version and snapshots the prior state', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.associate(record.id, { kind: 'tag', label: 'favourite' }); + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(2); + const versions = await stack.getVersions(record.id); + expect(versions).toHaveLength(1); + expect(versions[0].version).toBe(1); + expect(versions[0].associations ?? []).toEqual([]); + }); + + test('associate is a no-op for a duplicate association — no version bump', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.associate(record.id, { kind: 'tag', label: 'favourite' }); + await stack.associate(record.id, { kind: 'tag', label: 'favourite' }); + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(2); + expect(await stack.getVersions(record.id)).toHaveLength(1); + }); + + test('associate throws StackNotFoundError for a missing record', async () => { + await expect(stack.associate('nonexistent', { kind: 'tag', label: 'x' })).rejects.toThrow( + StackNotFoundError, + ); + }); + + test('dissociate bumps version and snapshots the prior state', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.associate(record.id, { kind: 'tag', label: 'favourite' }); + await stack.dissociate(record.id, { kind: 'tag', label: 'favourite' }); + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(3); + expect(await stack.getVersions(record.id)).toHaveLength(2); + }); + + test('dissociate is a no-op when the association is not present — no version bump', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.dissociate(record.id, { kind: 'tag', label: 'nonexistent' }); + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(1); + expect(await stack.getVersions(record.id)).toHaveLength(0); + }); + + test('dissociate throws StackNotFoundError for a missing record', async () => { + await expect(stack.dissociate('nonexistent', { kind: 'tag', label: 'x' })).rejects.toThrow( + StackNotFoundError, + ); + }); +}); + +// ------------------------------------------------------- +// setPermissions +// ------------------------------------------------------- + +describe('setPermissions', () => { + test('bumps version and snapshots the prior state', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.setPermissions(record.id, [{ access: 'public' }]); + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(2); + expect(updated?.permissions).toEqual([{ access: 'public' }]); + const versions = await stack.getVersions(record.id); + expect(versions).toHaveLength(1); + expect(versions[0].permissions ?? []).toEqual([]); + }); + + test('is a no-op for a deep-equal permission set — no version bump', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.setPermissions(record.id, [{ access: 'public' }]); + await stack.setPermissions(record.id, [{ access: 'public' }]); + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(2); + expect(await stack.getVersions(record.id)).toHaveLength(1); + }); + + test('setting empty permissions on an already-private record is a no-op', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.setPermissions(record.id, []); + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(1); + }); + + test('throws StackNotFoundError for a missing record', async () => { + await expect(stack.setPermissions('nonexistent', [{ access: 'public' }])).rejects.toThrow( + StackNotFoundError, + ); + }); }); // ------------------------------------------------------- diff --git a/packages/record-adapter-sqljs/src/index.ts b/packages/record-adapter-sqljs/src/index.ts index 0d94c08..b54abf5 100644 --- a/packages/record-adapter-sqljs/src/index.ts +++ b/packages/record-adapter-sqljs/src/index.ts @@ -87,11 +87,13 @@ const SCHEMA_SQL = ` ) STRICT; CREATE TABLE IF NOT EXISTS versions ( - record_id TEXT NOT NULL REFERENCES records(id), - version INTEGER NOT NULL, - content TEXT NOT NULL CHECK (json_valid(content)), - updated_at INTEGER NOT NULL, - entity_id TEXT, + record_id TEXT NOT NULL REFERENCES records(id), + version INTEGER NOT NULL, + content TEXT NOT NULL CHECK (json_valid(content)), + updated_at INTEGER NOT NULL, + entity_id TEXT, + associations TEXT CHECK (associations IS NULL OR json_valid(associations)), + permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)), PRIMARY KEY (record_id, version) ) STRICT; @@ -202,6 +204,8 @@ const rowToVersion = (row: Record): RecordVersion => { updatedAt: fromMs(row.updated_at as number), }; if (row.entity_id) v.entityId = row.entity_id as string; + if (row.associations) v.associations = JSON.parse(row.associations as string); + if (row.permissions) v.permissions = JSON.parse(row.permissions as string); return v; }; @@ -699,14 +703,17 @@ export class SQLiteRecordAdapter implements StackRecordAdapter { async saveVersion(id: string, version: RecordVersion): Promise { this.db.run( - `INSERT OR IGNORE INTO versions (record_id, version, content, updated_at, entity_id) - VALUES (?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO versions + (record_id, version, content, updated_at, entity_id, associations, permissions) + VALUES (?, ?, ?, ?, ?, ?, ?)`, [ id, version.version, JSON.stringify(version.content), toMs(version.updatedAt), version.entityId ?? null, + version.associations ? JSON.stringify(version.associations) : null, + version.permissions ? JSON.stringify(version.permissions) : null, ], ); this.persist(); diff --git a/packages/record-adapter-sqljs/tests/record.test.ts b/packages/record-adapter-sqljs/tests/record.test.ts index 516fca0..c271fd0 100644 --- a/packages/record-adapter-sqljs/tests/record.test.ts +++ b/packages/record-adapter-sqljs/tests/record.test.ts @@ -775,6 +775,36 @@ describe('versions', () => { const versions = await adapter.getVersions(record.id); expect(versions.length).toBe(1); }); + + test('saveVersion and getVersion roundtrip associations and permissions', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.saveVersion(record.id, { + version: 1, + content: { text: 'original' }, + updatedAt: new Date(), + associations: [{ kind: 'tag', label: 'starred' }], + permissions: [{ access: 'public' }], + }); + const retrieved = await adapter.getVersion(record.id, 1); + expect(retrieved?.associations).toEqual([{ kind: 'tag', label: 'starred' }]); + expect(retrieved?.permissions).toEqual([{ access: 'public' }]); + }); + + test('version without associations/permissions omits them, not nulls', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.saveVersion(record.id, { + version: 1, + content: { text: 'original' }, + updatedAt: new Date(), + }); + const retrieved = await adapter.getVersion(record.id, 1); + expect(retrieved?.associations).toBeUndefined(); + expect(retrieved?.permissions).toBeUndefined(); + }); }); // ------------------------------------------------------- diff --git a/packages/wire-types/src/index.ts b/packages/wire-types/src/index.ts index 2d912d4..de5e813 100644 --- a/packages/wire-types/src/index.ts +++ b/packages/wire-types/src/index.ts @@ -37,6 +37,8 @@ export type WireVersion = { content: Record; updatedAt: string; entityId?: string; + associations?: Association[]; + permissions?: Permission[]; }; export function serializeRecord(r: StackRecord): WireRecord { @@ -78,6 +80,8 @@ export function serializeVersion(v: RecordVersion): WireVersion { updatedAt: v.updatedAt.toISOString(), }; if (v.entityId !== undefined) w.entityId = v.entityId; + if (v.associations !== undefined) w.associations = v.associations; + if (v.permissions !== undefined) w.permissions = v.permissions; return w; }