Skip to content
This repository was archived by the owner on Sep 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,17 @@ git push
git push --tags
pnpm build:release
pnpm publish
```
```
The native resource API accepts HTTP(S) and DID identities, including AtomicServer `did:ad:` resources, properties and links. Identity allocation still uses an HTTP(S) base; bind existing DID resources explicitly. See [Atomic Data API](docs/atomic-data.md).

### Passive platform lenses

Provider transformations live in `platform-lenses/github-issues/lens/` and
`platform-lenses/google-calendar/lens/`, exposed through the corresponding
`devonian/platform-lenses/<platform>/lens` package entry points. These functions
consume supplied data and return projections or patches; they do not fetch,
subscribe, persist, or checkpoint. Their surrounding platform modules retain
existing runtime behavior and compatibility entry points.

See [the platform lens boundaries](docs/atomic-data.md#passive-platform-lenses)
for the forward and reverse mappings and their scope.
33 changes: 33 additions & 0 deletions __tests__/atomic/lens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,36 @@ describe('Atomic Extract Entity lens', () => {
expect(connector.delete).toHaveBeenCalledTimes(2);
});
});

it('ingests and republishes an explicitly bound DID after restart without duplication', async () => {
const first = setup();
const subject = 'did:ad:existing/resource+signature==';
const scope = { scope: 'did:ad:agent:account=', entity: 'event' };
const makeLens = (state: ReturnType<typeof setup>): AtomicLens<FlatOrder> =>
new AtomicLens({
store: state.store,
identities: state.ids,
connector: state.connector,
...scope,
read: (record: FlatOrder): { set: Record<string, number> } => ({
set: { [v.quantity]: record.quantity },
}),
write: (resource, previous): FlatOrder => ({
...previous!,
quantity: resource[v.quantity] as number,
}),
});
first.ids.bind(scope, input.id, subject);
await makeLens(first).ingest(input);
const restored = setup(first.store.toJSONAD());
restored.records.set(input.id, input);
const lens = makeLens(restored);
expect(await lens.ingest(input)).toBe(subject);
restored.store.patch(subject, { set: { [v.quantity]: 4 } });
await lens.publish(subject);
expect(restored.connector.create).not.toHaveBeenCalled();
expect(restored.records.get(input.id)?.quantity).toBe(4);
expect(restored.store.all().filter((r) => r['@id'] === subject)).toHaveLength(
1,
);
});
50 changes: 49 additions & 1 deletion __tests__/atomic/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ describe('AtomicStore', () => {
original[link][name] = 'mutated';
const copy = store.get(a)!;
(copy[link] as Record<string, string>)[name] = 'also mutated';
const [parsed] = new JSONADParser().parseArray(JSON.parse(store.toJSONAD()));
const [parsed] = new JSONADParser().parseArray(
JSON.parse(store.toJSONAD()),
);
expect(parsed).toHaveLength(1);
const restored = setup();
restored.loadJSONAD(store.toJSONAD());
Expand Down Expand Up @@ -143,3 +145,49 @@ describe('AtomicIdentityMap', () => {
expect(() => ids.bind(scope, '', b)).toThrow();
});
});

describe('DID resources', () => {
const subject = 'did:ad:abc/def+ghi==';
const property = 'did:ad:property';
const classId = 'did:ad:class';
const scope = { scope: 'did:ad:agent:abc+def=', entity: 'event' };
const create = (): AtomicStore =>
new AtomicStore(new AtomicSchema().property(property, Datatype.ATOMIC_URL));

it('preserves DID subjects, property keys, classes, links and identity mappings across restart', () => {
const store = create();
const ids = new AtomicIdentityMap(store, 'https://example.com/bridge');
store.put({ '@id': subject, [property]: scope.scope, [IS_A]: [classId] });
ids.bind(scope, 'event-1', subject);
const restored = create();
const restoredIds = new AtomicIdentityMap(
restored,
'https://example.com/bridge',
);
restored.loadJSONAD(store.toJSONAD());
expect(restored.all(classId)[0]['@id']).toBe(subject);
expect(restoredIds.subjectFor(scope, 'event-1')).toBe(subject);
expect(restoredIds.externalId(scope, subject)).toBe('event-1');
restored.patch(subject, { set: { [property]: 'did:key:z6MkExample' } });
expect(restored.get(subject)?.[property]).toBe('did:key:z6MkExample');
restored.patch(subject, { unset: [property] });
expect(restored.get(subject)?.[property]).toBeUndefined();
});

it.each([
'did:',
'did:ad:',
'did::abc',
'did:AD:abc',
'did:ad:has space',
'did:ad:%ZZ',
'javascript:alert(1)',
'file:///tmp/resource',
])('rejects invalid subject %s', (id) => {
expect(() => create().put({ '@id': id })).toThrow();
});

it('requires an HTTP(S) allocator base even when resources use DIDs', () => {
expect(() => new AtomicIdentityMap(create(), subject)).toThrow('HTTP(S)');
});
});
174 changes: 174 additions & 0 deletions __tests__/unit/platformLenses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest';
import {
project,
unproject,
issuePatch,
type Issue,
} from '../../platform-lenses/github-issues/lens/index.js';
import { planCalendarValues } from '../../platform-lenses/google-calendar/lens/edit.js';
import {
applyCalendarEdit,
planCalendarEdit,
} from '../../platform-lenses/google-calendar/sync.js';

const issue: Issue & { assignee: string } = {
number: 42,
title: 'Before',
body: null,
state: 'open',
labels: ['bug', { name: 'ATOMIC:DOING' }],
assignee: 'someone',
};
const name = 'https://atomicdata.dev/properties/name';
const baseline = {
[name]: 'Before',
summary: 'Before',
description: 'Details',
};
const properties = {
summary: 'summary',
description: 'description',
start: 'start',
end: 'end',
};
const remote = {
summary: 'Before',
description: 'Details',
attendees: [{ email: 'person@example.com' }],
};

describe('passive platform lenses', () => {
it.each(['Todo', 'Doing', 'Done'] as const)(
'round trips GitHub %s without changing unrelated data',
(status) => {
const desired = { title: 'After', body: 'Edited', status };
const previous = structuredClone(issue);
const result = unproject(desired, issue);
expect(project(result)).toEqual(desired);
expect(result.number).toBe(42);
expect(result.assignee).toBe('someone');
expect(result.labels).toContain('bug');
expect(issue).toEqual(previous);
expect(unproject(desired, result)).toEqual(result);
},
);

it('keeps the GitHub runtime patch minimal', () => {
const before = project(issue);
expect(issuePatch(before, before)).toEqual({});
expect(issuePatch({ ...before, status: 'Done' }, before)).toEqual({
state: 'closed',
});
});

it('maps Calendar edits without replacing provider-only fields or mutating input', () => {
const row = { ...baseline, [name]: 'After', description: '' };
const previous = structuredClone(row);
const edit = planCalendarValues(row, baseline, properties, remote)!;
expect(edit.patch).toEqual({ summary: 'After', description: '' });
expect({ ...remote, ...edit.patch }.attendees).toEqual(remote.attendees);
expect(row).toEqual(previous);
const acknowledged = { ...row, ...edit.acknowledged };
expect(
planCalendarValues(acknowledged, acknowledged, properties, {
...remote,
...edit.patch,
}),
).toBeUndefined();
});

it('rejects competing Calendar edits and invalid intervals', () => {
expect(() =>
planCalendarValues(
{ ...baseline, [name]: 'Local' },
baseline,
properties,
{ ...remote, summary: 'Remote' },
),
).toThrow('conflict');
expect(() =>
planCalendarValues(
{ ...baseline, start: { date: '2026-09-12' } },
baseline,
properties,
{ ...remote, end: { date: '2026-09-11' } },
),
).toThrow('end must follow');
});
});

describe('Calendar runtime after lens extraction', () => {
const baselineKey = 'https://atomicdata.dev/properties/importBaseline';
const config = {
platform: 'google-calendar',
destinations: { event: { table: 'urn:calendar', rowClass: 'urn:event' } },
properties,
};
const row = {
...baseline,
[name]: 'After',
[baselineKey]: { values: baseline },
'https://atomicdata.dev/properties/localId': JSON.stringify([
'google-calendar',
'event',
'primary',
'42',
]),
'https://atomicdata.dev/properties/parent': 'urn:calendar',
'https://atomicdata.dev/properties/isA': ['urn:event'],
};

it('does not checkpoint failed writes', async () => {
const edit = planCalendarEdit('urn:event:42', row, config, {
...remote,
etag: 'v1',
})!;
let checkpointed = false;
await expect(
applyCalendarEdit(
edit,
async () => row,
async () => ({ status: 503, body: '' }),
async () => {
checkpointed = true;
},
),
).rejects.toThrow('503');
expect(checkpointed).toBe(false);
});

it('recovers a lost checkpoint without repeating the remote write', async () => {
const edit = planCalendarEdit('urn:event:42', row, config, {
...remote,
etag: 'v1',
})!;
const savedRemote = { ...remote, ...edit.patch, etag: 'v2' };
await expect(
applyCalendarEdit(
edit,
async () => row,
async () => ({ status: 200, body: JSON.stringify(savedRemote) }),
async () => {
throw new Error('Checkpoint unavailable');
},
),
).rejects.toThrow('Checkpoint unavailable');
const replay = planCalendarEdit('urn:event:42', row, config, savedRemote)!;
expect(replay.patch).toEqual({});
let writes = 0;
let checkpoint: unknown;
await applyCalendarEdit(
replay,
async () => row,
async () => {
writes++;
throw new Error('Unexpected write');
},
async (values) => {
checkpoint = values;
},
);
expect(writes).toBe(0);
expect(checkpoint).toMatchObject({ [name]: 'After', summary: 'After' });
});
});
26 changes: 26 additions & 0 deletions __tests__/unit/reconcileRecord.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { reconcileRecord } from '../../src/reconcileRecord.js';

describe('reconcileRecord', () => {
it('marks equal observations as agreed without changes', () => {
const decision = reconcileRecord(
{ title: 'before' },
{ title: 'same' },
{ title: 'same' },
);
expect(decision.conflicts).toEqual([]);
expect(decision.agreed).toEqual({ title: 'same' });
expect(decision.local).toEqual({});
expect(decision.remote).toEqual({});
});

it('reports a conflict when both sides edit the same field', () => {
const decision = reconcileRecord(
{ title: 'before' },
{ title: 'local' },
{ title: 'remote' },
);
expect(decision.conflicts).toHaveLength(1);
expect(decision.conflicts[0].property).toBe('title');
});
});
11 changes: 10 additions & 1 deletion __tests__/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ import {

export default defineConfig({
test: {
exclude: [...configDefaults.exclude, 'build/**/*'],
exclude: [
...configDefaults.exclude,
'build/**/*',
// These GitHub package tests exercise the Atomic host contract and run
// in the consuming application, which provides the host dependencies.
'platform-lenses/github-issues/adapter.test.ts',
'platform-lenses/github-issues/atomic.live.test.ts',
'platform-lenses/github-issues/automation.test.ts',
'platform-lenses/github-issues/github.live.test.ts',
],
coverage: {
provider: 'v8',
exclude: [...coverageConfigDefaults.exclude, 'build/**/*'],
Expand Down
37 changes: 35 additions & 2 deletions docs/atomic-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ store.patch(subject, { unset: [title] });

`apply([{ subject, patch }, ...])` validates and applies related resource changes as a local transaction. A validation failure leaves all resources untouched. `transaction(callback)` groups synchronous store and identity operations with rollback; the callback must not be async or launch asynchronous work.

`AtomicSchema` is an offline property catalog. Define every property before importing data; unknown properties are rejected. It uses `@tomic/lib` datatype validation, with additional primitive, finite-number and link checks. This release supports the SDK's string, markdown, slug, boolean, integer, float, date, timestamp, atomicURL and resourceArray types. Links may be HTTP(S) URLs or nested resources with property URL keys and no `@id`. Named resources are permitted at the JSON-AD document root or in its root array.
`AtomicSchema` is an offline property catalog. Define every property before importing data; unknown properties are rejected. It uses `@tomic/lib` datatype validation, with additional primitive, finite-number and link checks. This release supports the SDK's string, markdown, slug, boolean, integer, float, date, timestamp, atomicURL and resourceArray types. Links may be HTTP(S) URLs or DID identifiers or nested resources with property URL keys and no `@id`. Named resources are permitted at the JSON-AD document root or in its root array.

This is a deliberately bounded JSON-AD profile: HTTP(S) subjects, explicitly registered datatypes, no arbitrary JSON or Yjs datatypes, no automatic remote schema resolution, and no required-property/class-constraint validation. Application lenses must check required fields. Publish your property's Atomic Schema definitions at their URLs for interoperability; declaring a local catalog does not publish them. The example.com vocabulary in examples is illustrative.
This is a deliberately bounded JSON-AD profile: HTTP(S) or DID subjects, explicitly registered datatypes, no arbitrary JSON or Yjs datatypes, no automatic remote schema resolution, and no required-property/class-constraint validation. Application lenses must check required fields. Publish your property's Atomic Schema definitions at their URLs for interoperability; declaring a local catalog does not publish them. The example.com vocabulary in examples is illustrative.

## External identities

Expand Down Expand Up @@ -105,3 +105,36 @@ There are no revision clocks, stale-event rejection, cross-system conflict resol
The behavioral tests cover distinct identical records, scoped identities, round-trip snapshots, malformed input rollback, linked entity extraction, reverse updates, field preservation/removal, replay, queued creates, lost responses and deletion failures.

Specification references: [JSON-AD](https://docs.atomicdata.dev/core/json-ad.html), [Atomic Schema](https://docs.atomicdata.dev/schema/intro.html), [Atomic Commits](https://docs.atomicdata.dev/commits/intro.html), [TypeScript SDK](https://atomicdata-dev.github.io/atomic-data-browser/docs/modules/_tomic_lib.html).

DID identifiers (including AtomicServer `did:ad:` base64 identities) are accepted as resource subjects, property keys, classes, links and connector scopes. Identifiers are preserved exactly; validation does not resolve DIDs or verify signatures. Identity allocator bases still require HTTP(S) URLs because the allocator appends child paths. Bind existing DID resources explicitly before ingesting their external records.

## Passive platform lenses

The `platform-lenses/github-issues/lens/` and
`platform-lenses/google-calendar/lens/` directories contain passive mappings.
A caller supplies resource data; these modules neither access datasets nor own
credentials, subscriptions, identity lookups, or durable synchronization state.

- GitHub's `project(issue)` maps issue fields to title/body/status.
`unproject(value, previousIssue)` maps back while preserving the issue number,
unrelated fields, and labels other than `atomic:doing`. The reverse mapping
normalizes that workflow label to the requested status. `issueFields` and
`issuePatch` support the existing runtime's field writes. The bridge's Atomic
property mapping for issues and comments lives in `lens/resources.mjs`.
- Calendar's `calendarProjection` and `calendarRecurrenceProjection` add Atomic
display/recurrence properties while retaining provider fields.
`planCalendarValues(row, baseline, properties, remote)` computes reverse
patches for summary, description, location, start, and end from supplied data.
It retains the existing field conflict and interval validation. It does not
write recurrence changes or replace the complete Google event. Empty strings
explicitly clear supported text fields.

Import these through `devonian/platform-lenses/github-issues/lens` or
`devonian/platform-lenses/google-calendar/lens`. Calendar's projection helpers
still require the consuming application's compatible `@tomic/lib` calendar
helpers. Previous adapter, projection, recurrence, types, and sync entry points
remain available.

Transport, dataset membership checks, preview workflows, ETags, and checkpointing
remain in the surrounding platform modules. This extraction does not introduce
an event handler, dataset abstraction, Loro replay, or distributed convergence.
Loading
Loading