ofsync-shared-core 0.2.0-alpha.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/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # ofsync-shared-core
2
+
3
+ `ofsync-shared-core` is the host-side sync orchestration core for the `of*` ecosystem.
4
+
5
+ This package standardizes offline-first synchronization flow for multi-domain host apps, while keeping:
6
+ - **offline-first** behavior,
7
+ - **database agnostic** runtime contracts,
8
+ - **platform agnostic** integration boundaries.
9
+
10
+ ## Scope
11
+ - local outbox/inbox orchestration,
12
+ - sync scheduling + retry/backoff policy,
13
+ - conflict pipeline with resolver hooks,
14
+ - cross-domain local projection/event orchestration,
15
+ - transport bridge to `ofsync-server` via adapter contracts.
16
+
17
+ ## Boundary Rules
18
+ - keep business logic inside `of[domain]-shared-core`,
19
+ - do not hardcode DB drivers or platform APIs in core runtime,
20
+ - consume generic primitives from `ofcore` and domain contracts from domain shared-cores.
21
+
22
+ ## Status
23
+ - Current status: **phase 0-5 implemented**.
24
+ - Source RFC: `docs/rfc/007-ofsync-shared-core-offline-first-host-sync-orchestration.md` (in POS workspace).
25
+
26
+ ## Documentation
27
+ - Documentation hub: `docs/00-DOCUMENTATION-HUB.md`
28
+ - Architecture intent: `docs/10-ARCHITECTURE-INTENT.md`
@@ -0,0 +1,76 @@
1
+ import type { ActivitySink, CoreAdapterRegistry, CoreRuntime, CoreRuntimeStartOptions, HttpAdapter, LoggerAdapter, PlatformAdapter, SocketAdapter, DbAdapter } from 'ofcore';
2
+ import type { ReadonlyStore, Store } from 'ofcore';
3
+ import type { OutboxItem, SyncChange, SyncPolicy, SyncScope } from './contracts/SyncContract';
4
+ import type { DomainBridge } from './services/DomainBridge';
5
+ import type { CheckpointStore } from './services/InMemoryCheckpointStore';
6
+ import type { OutboxStore } from './services/InMemoryOutboxStore';
7
+ import { type ConflictResolver } from './services/ConflictResolver';
8
+ import type { SyncTransport } from './services/SyncTransport';
9
+ export interface OfsyncRuntimeState {
10
+ phase: 'idle' | 'starting' | 'started' | 'syncing' | 'stopping' | 'stopped' | 'error';
11
+ started: boolean;
12
+ syncing: boolean;
13
+ startCount: number;
14
+ stopCount: number;
15
+ syncCount: number;
16
+ lastError: string | null;
17
+ lastTransitionAt: string;
18
+ }
19
+ export interface OfsyncCoreOptions {
20
+ policy?: Partial<SyncPolicy>;
21
+ transport?: SyncTransport;
22
+ outboxStore?: OutboxStore;
23
+ checkpointStore?: CheckpointStore;
24
+ conflictResolvers?: ConflictResolver[];
25
+ projectionHook?: (change: SyncChange, scope: SyncScope) => Promise<void> | void;
26
+ }
27
+ export declare class OfsyncCore {
28
+ private readonly runtime;
29
+ private readonly runtimeStateStore;
30
+ private readonly bridges;
31
+ private readonly policy;
32
+ private readonly transport?;
33
+ private readonly outboxStore;
34
+ private readonly checkpointStore;
35
+ private readonly conflictResolvers;
36
+ private readonly projectionHook?;
37
+ private schedulerTimer?;
38
+ readonly runtimeStore: ReadonlyStore<OfsyncRuntimeState>;
39
+ constructor(runtime: CoreRuntime<never, never>, runtimeStateStore: Store<OfsyncRuntimeState>, options?: OfsyncCoreOptions);
40
+ static builder(): OfsyncCoreBuilder;
41
+ get registry(): CoreAdapterRegistry | undefined;
42
+ getPolicy(): SyncPolicy;
43
+ registerDomainBridge(bridge: DomainBridge): void;
44
+ listDomainBridges(): string[];
45
+ enqueueLocalChange(change: SyncChange, idempotencyKey?: string): Promise<OutboxItem>;
46
+ start(options?: CoreRuntimeStartOptions): Promise<void>;
47
+ stop(): Promise<void>;
48
+ startSync(scope: SyncScope): Promise<void>;
49
+ stopSync(): Promise<void>;
50
+ isStarted(): boolean;
51
+ startScheduler(scope: SyncScope): void;
52
+ stopScheduler(): void;
53
+ notifyOnline(scope: SyncScope): Promise<void>;
54
+ private processOutbox;
55
+ private applyRemoteChanges;
56
+ private resolveConflicts;
57
+ }
58
+ export declare class OfsyncCoreBuilder {
59
+ private readonly runtimeBuilder;
60
+ private options;
61
+ constructor();
62
+ withPlatformAdapter(factory: (core: CoreRuntime<any, any>) => PlatformAdapter): this;
63
+ withDbAdapter(factory: (core: CoreRuntime<any, any>) => DbAdapter): this;
64
+ withHttpAdapter(factory: (core: CoreRuntime<any, any>) => HttpAdapter): this;
65
+ withSocketAdapter(factory: (core: CoreRuntime<any, any>) => SocketAdapter): this;
66
+ withLoggerAdapter(factory: (core: CoreRuntime<any, any>) => LoggerAdapter): this;
67
+ withActivitySink(factory: (core: CoreRuntime<any, any>) => ActivitySink): this;
68
+ withExtension(name: string, factory: (core: CoreRuntime<any, any>) => unknown): this;
69
+ withPolicy(policy: Partial<SyncPolicy>): this;
70
+ withTransport(transport: SyncTransport): this;
71
+ withOutboxStore(store: OutboxStore): this;
72
+ withCheckpointStore(store: CheckpointStore): this;
73
+ withConflictResolvers(resolvers: ConflictResolver[]): this;
74
+ withProjectionHook(hook: (change: SyncChange, scope: SyncScope) => Promise<void> | void): this;
75
+ build(): OfsyncCore;
76
+ }
@@ -0,0 +1,44 @@
1
+ import type { HttpAdapter } from 'ofcore';
2
+ import type { ConflictItem, OutboxItem, SyncScope } from '../contracts/SyncContract';
3
+ import type { SyncPullResult, SyncPushResult, SyncTransport } from '../services/SyncTransport';
4
+ export interface OfsyncHttpTransportOptions {
5
+ baseUrl: string;
6
+ httpAdapter: HttpAdapter;
7
+ getAccessToken?: () => string | null | undefined | Promise<string | null | undefined>;
8
+ nowIso?: () => string;
9
+ }
10
+ export declare class OfsyncTransportError extends Error {
11
+ readonly code: string;
12
+ readonly retryable: boolean;
13
+ readonly status?: number;
14
+ constructor(code: string, message: string, retryable?: boolean, status?: number);
15
+ }
16
+ export declare class OfsyncHttpTransport implements SyncTransport {
17
+ private readonly options;
18
+ constructor(options: OfsyncHttpTransportOptions);
19
+ private nowIso;
20
+ private buildHeaders;
21
+ private normalizeUrl;
22
+ private parseFailure;
23
+ push(params: {
24
+ scope: SyncScope;
25
+ outbox: OutboxItem[];
26
+ }): Promise<SyncPushResult>;
27
+ pull(params: {
28
+ scope: SyncScope;
29
+ cursor: string | null;
30
+ limit: number;
31
+ }): Promise<SyncPullResult>;
32
+ listConflicts(params: {
33
+ scope: SyncScope;
34
+ }): Promise<ConflictItem[]>;
35
+ resolveConflicts(params: {
36
+ scope: SyncScope;
37
+ resolutions: Array<{
38
+ conflictId: string;
39
+ action: 'APPLY_REMOTE' | 'KEEP_LOCAL' | 'MANUAL_MERGE';
40
+ }>;
41
+ }): Promise<{
42
+ resolvedCount: number;
43
+ }>;
44
+ }
package/dist/api.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { CoreRuntimeStartOptions } from 'ofcore';
2
+ import { OfsyncCore } from './OfsyncCore';
3
+ import type { OfsyncCoreOptions } from './OfsyncCore';
4
+ import type { DomainBridge } from './services/DomainBridge';
5
+ import type { SyncScope } from './contracts/SyncContract';
6
+ export declare function createSyncRuntime(options?: OfsyncCoreOptions): OfsyncCore;
7
+ export declare function registerDomainBridge(runtime: OfsyncCore, bridge: DomainBridge): void;
8
+ export declare function startSync(runtime: OfsyncCore, scope: SyncScope, runtimeStartOptions?: CoreRuntimeStartOptions): Promise<void>;
9
+ export declare function stopSync(runtime: OfsyncCore): Promise<void>;
@@ -0,0 +1,59 @@
1
+ export interface SyncScope {
2
+ tenantId?: string;
3
+ branchId?: string;
4
+ deviceId?: string;
5
+ }
6
+ export type SyncChangeType = 'CREATE' | 'UPDATE' | 'DELETE';
7
+ export interface SyncChange {
8
+ id: string;
9
+ domain: string;
10
+ entity: string;
11
+ type: SyncChangeType;
12
+ recordId: string;
13
+ payload: Record<string, unknown>;
14
+ occurredAt: string;
15
+ scope?: SyncScope;
16
+ }
17
+ export interface OutboxItem {
18
+ outboxId: string;
19
+ idempotencyKey: string;
20
+ change: SyncChange;
21
+ retryCount: number;
22
+ status: 'PENDING' | 'IN_FLIGHT' | 'FAILED' | 'APPLIED';
23
+ lastError?: string;
24
+ createdAt: string;
25
+ updatedAt: string;
26
+ }
27
+ export type ConflictCode = 'VERSION_CONFLICT' | 'SCOPE_CONFLICT' | 'REFERENCE_CONFLICT' | 'BUSINESS_RULE_CONFLICT' | 'UNKNOWN_CONFLICT';
28
+ export interface ConflictItem {
29
+ conflictId: string;
30
+ changeId: string;
31
+ domain: string;
32
+ entity: string;
33
+ recordId: string;
34
+ code: ConflictCode;
35
+ message: string;
36
+ retryable: boolean;
37
+ scope?: SyncScope;
38
+ details?: Record<string, unknown>;
39
+ }
40
+ export interface SyncPolicy {
41
+ batchSize: number;
42
+ maxRetry: number;
43
+ scheduler: {
44
+ mode: 'manual' | 'interval';
45
+ intervalMs?: number;
46
+ onlineTrigger?: boolean;
47
+ };
48
+ retryBackoff: {
49
+ strategy: 'none' | 'fixed' | 'exponential-jitter';
50
+ baseDelayMs?: number;
51
+ maxDelayMs?: number;
52
+ };
53
+ }
54
+ export interface SyncCursor {
55
+ scopeKey: string;
56
+ value: string | null;
57
+ updatedAt: string;
58
+ }
59
+ export declare const DEFAULT_SYNC_POLICY: SyncPolicy;
@@ -0,0 +1,10 @@
1
+ export * from './contracts/SyncContract';
2
+ export * from './services/DomainBridge';
3
+ export * from './services/SyncTransport';
4
+ export * from './services/InMemoryOutboxStore';
5
+ export * from './services/InMemoryCheckpointStore';
6
+ export * from './services/ConflictResolver';
7
+ export * from './runtime/ContractStage';
8
+ export * from './OfsyncCore';
9
+ export * from './api';
10
+ export * from './adapters/OfsyncHttpTransport';
@@ -0,0 +1 @@
1
+ var d={batchSize:200,maxRetry:5,scheduler:{mode:"manual",onlineTrigger:!0},retryBackoff:{strategy:"exponential-jitter",baseDelayMs:500,maxDelayMs:3e4}};var y=class{constructor(){this.rows=new Map}async enqueue(t){this.rows.set(t.outboxId,t)}async listPending(t){return[...this.rows.values()].filter(e=>e.status==="PENDING"||e.status==="FAILED").sort((e,n)=>e.createdAt.localeCompare(n.createdAt)).slice(0,Math.max(0,t))}async findByIdempotencyKey(t){for(let e of this.rows.values())if(e.idempotencyKey===t)return e;return null}async update(t){this.rows.set(t.outboxId,t)}async remove(t){this.rows.delete(t)}};var h=class{constructor(){this.rows=new Map}async get(t){return this.rows.has(t)?this.rows.get(t):null}async set(t,e){this.rows.set(t,e)}};function f(r){return r.code==="VERSION_CONFLICT"?{conflictId:r.conflictId,action:"APPLY_REMOTE"}:r.code==="SCOPE_CONFLICT"?{conflictId:r.conflictId,action:"MANUAL_MERGE"}:{conflictId:r.conflictId,action:"KEEP_LOCAL"}}var _="phase1-runtime-skeleton";import{CoreRuntime as C}from"ofcore";import{InMemoryDbAdapter as I}from"ofcore";import{asReadonlyStore as w,createStore as v}from"ofcore";function x(r={}){return{...d,...r,scheduler:{...d.scheduler,...r.scheduler||{}},retryBackoff:{...d.retryBackoff,...r.retryBackoff||{}}}}function A(r){return`${r.tenantId||"__standalone__"}::${r.branchId||"__all__"}::${r.deviceId||"__device__"}`}var u=class{constructor(t,e,n={}){this.runtime=t;this.runtimeStateStore=e;this.bridges=new Map;this.runtimeStore=w(this.runtimeStateStore),this.policy=x(n.policy),this.transport=n.transport,this.outboxStore=n.outboxStore||new y,this.checkpointStore=n.checkpointStore||new h,this.conflictResolvers=n.conflictResolvers||[],this.projectionHook=n.projectionHook}static builder(){return new S}get registry(){return this.runtime.registry}getPolicy(){return this.policy}registerDomainBridge(t){let e=t.domain.trim();if(!e)throw new Error("DomainBridge.domain is required");if(this.bridges.has(e))throw new Error(`DomainBridge for domain "${e}" is already registered`);this.bridges.set(e,t)}listDomainBridges(){return[...this.bridges.keys()].sort()}async enqueueLocalChange(t,e){let n=(e||`${t.domain}:${t.entity}:${t.recordId}:${t.type}:${t.id}`).trim();if(!n)throw new Error("idempotencyKey must not be empty");let i=await this.outboxStore.findByIdempotencyKey(n);if(i)return i;let c=new Date().toISOString(),s={outboxId:`outbox_${Date.now()}_${Math.random().toString(36).slice(2)}`,idempotencyKey:n,change:t,retryCount:0,status:"PENDING",createdAt:c,updatedAt:c};return await this.outboxStore.enqueue(s),s}async start(t={}){this.runtimeStateStore.setState({phase:"starting",started:!1,syncing:!1,lastError:null,lastTransitionAt:new Date().toISOString()});try{await this.runtime.start(t),this.runtimeStateStore.setState(e=>({...e,phase:"started",started:!0,syncing:!1,startCount:e.startCount+1,lastError:null,lastTransitionAt:new Date().toISOString()}))}catch(e){throw this.runtimeStateStore.setState({phase:"error",started:!1,syncing:!1,lastError:e instanceof Error?e.message:String(e),lastTransitionAt:new Date().toISOString()}),e}}async stop(){this.stopScheduler(),this.runtimeStateStore.setState({phase:"stopping",started:this.runtime.isStarted(),syncing:!1,lastError:null,lastTransitionAt:new Date().toISOString()});try{await this.runtime.stop(),this.runtimeStateStore.setState(t=>({...t,phase:"stopped",started:!1,syncing:!1,stopCount:t.stopCount+1,lastError:null,lastTransitionAt:new Date().toISOString()}))}catch(t){throw this.runtimeStateStore.setState({phase:"error",started:this.runtime.isStarted(),syncing:!1,lastError:t instanceof Error?t.message:String(t),lastTransitionAt:new Date().toISOString()}),t}}async startSync(t){if(!this.runtime.isStarted())throw new Error("Cannot start sync before runtime is started");this.runtimeStateStore.setState({phase:"syncing",started:!0,syncing:!0,lastError:null,lastTransitionAt:new Date().toISOString()});try{let e=A(t),n=await this.checkpointStore.get(e);if(this.transport){await this.processOutbox(t);let i=await this.transport.pull({scope:t,cursor:n,limit:this.policy.batchSize});await this.applyRemoteChanges(i.changes||[],t),i.nextCursor!==void 0&&await this.checkpointStore.set(e,i.nextCursor)}this.runtimeStateStore.setState(i=>({...i,phase:"started",started:!0,syncing:!1,syncCount:i.syncCount+1,lastError:null,lastTransitionAt:new Date().toISOString()}))}catch(e){throw this.runtimeStateStore.setState({phase:"error",started:this.runtime.isStarted(),syncing:!1,lastError:e instanceof Error?e.message:String(e),lastTransitionAt:new Date().toISOString()}),e}}async stopSync(){this.runtimeStateStore.setState(t=>({...t,phase:this.runtime.isStarted()?"started":"stopped",syncing:!1,lastTransitionAt:new Date().toISOString()}))}isStarted(){return this.runtime.isStarted()}startScheduler(t){if(this.policy.scheduler.mode!=="interval")return;let e=this.policy.scheduler.intervalMs||1e4;this.stopScheduler(),this.schedulerTimer=setInterval(()=>{this.startSync(t).catch(n=>{this.runtimeStateStore.setState(i=>({...i,phase:"error",syncing:!1,lastError:n instanceof Error?n.message:String(n),lastTransitionAt:new Date().toISOString()}))})},e)}stopScheduler(){this.schedulerTimer&&(clearInterval(this.schedulerTimer),this.schedulerTimer=void 0)}async notifyOnline(t){this.policy.scheduler.onlineTrigger!==!1&&await this.startSync(t)}async processOutbox(t){if(!this.transport)return;let e=await this.outboxStore.listPending(this.policy.batchSize);if(e.length===0)return;let n=await this.transport.push({scope:t,outbox:e}),i=new Set((n.applied||[]).map(o=>o.changeId)),c=new Map((n.failed||[]).map(o=>[o.changeId,o])),s=new Set((n.conflicts||[]).map(o=>o.changeId));await this.resolveConflicts(n.conflicts||[],t);for(let o of e){if(i.has(o.change.id)){await this.outboxStore.remove(o.outboxId);continue}let a=c.get(o.change.id);if(a||s.has(o.change.id)){let l=o.retryCount+1;await this.outboxStore.update({...o,retryCount:l,status:l>=this.policy.maxRetry?"FAILED":"PENDING",lastError:(a==null?void 0:a.message)||(s.has(o.change.id)?"SYNC_CONFLICT":o.lastError),updatedAt:new Date().toISOString()})}}}async applyRemoteChanges(t,e){if(!Array.isArray(t)||t.length===0)return;let n=[...t].sort((c,s)=>c.occurredAt.localeCompare(s.occurredAt)),i=new Map;for(let c of n){i.has(c.domain)||i.set(c.domain,[]);let s=i.get(c.domain);s&&s.push(c)}for(let[c,s]of i.entries()){let o=this.bridges.get(c);if(o!=null&&o.applyRemoteChanges&&(await o.applyRemoteChanges(s,e),this.projectionHook))for(let a of s)await this.projectionHook(a,e)}}async resolveConflicts(t,e){var i;if(!((i=this.transport)!=null&&i.resolveConflicts)||t.length===0)return;let n=[];for(let c of t){let s=[...this.conflictResolvers],o=null;for(let a of s){let l=await a(c,{scope:e});if(l){o=l;break}}o||(o=f(c)),n.push(o)}n.length>0&&await this.transport.resolveConflicts({scope:e,resolutions:n})}},S=class{constructor(){this.runtimeBuilder=C.builder();this.options={};this.runtimeBuilder.withDbAdapter(()=>new I)}withPlatformAdapter(t){return this.runtimeBuilder.withPlatformAdapter(t),this}withDbAdapter(t){return this.runtimeBuilder.withDbAdapter(t),this}withHttpAdapter(t){return this.runtimeBuilder.withHttpAdapter(t),this}withSocketAdapter(t){return this.runtimeBuilder.withSocketAdapter(t),this}withLoggerAdapter(t){return this.runtimeBuilder.withLoggerAdapter(t),this}withActivitySink(t){return this.runtimeBuilder.withActivitySink(t),this}withExtension(t,e){return this.runtimeBuilder.withExtension(t,e),this}withPolicy(t){return this.options={...this.options,policy:t},this}withTransport(t){return this.options={...this.options,transport:t},this}withOutboxStore(t){return this.options={...this.options,outboxStore:t},this}withCheckpointStore(t){return this.options={...this.options,checkpointStore:t},this}withConflictResolvers(t){return this.options={...this.options,conflictResolvers:t},this}withProjectionHook(t){return this.options={...this.options,projectionHook:t},this}build(){let t=v({phase:"idle",started:!1,syncing:!1,startCount:0,stopCount:0,syncCount:0,lastError:null,lastTransitionAt:new Date().toISOString()}),e=this.runtimeBuilder.build();return new u(e,t,this.options)}};function q(r={}){let t=u.builder();return r.policy&&t.withPolicy(r.policy),r.transport&&t.withTransport(r.transport),r.outboxStore&&t.withOutboxStore(r.outboxStore),r.checkpointStore&&t.withCheckpointStore(r.checkpointStore),r.conflictResolvers&&t.withConflictResolvers(r.conflictResolvers),r.projectionHook&&t.withProjectionHook(r.projectionHook),t.build()}function G(r,t){r.registerDomainBridge(t)}async function z(r,t,e={}){r.isStarted()||await r.start(e),await r.startSync(t)}async function Y(r){await r.stopSync()}var m=class extends Error{constructor(t,e,n=!1,i){super(e),this.name="OfsyncTransportError",this.code=t,this.retryable=n,this.status=i}};async function R(r){let t=globalThis.crypto;if(!t||!t.subtle)throw new Error("WebCrypto API is not available");let e=new TextEncoder().encode(r),n=await t.subtle.digest("SHA-256",e);return[...new Uint8Array(n)].map(i=>i.toString(16).padStart(2,"0")).join("")}function O(r){let t=String(r.table||""),e=String(r.id||""),n=String(r.changeId||"");return[t,e,n].join("::")}function g(r){return{conflictId:O(r),changeId:String(r.changeId||""),domain:"unknown",entity:String(r.table||""),recordId:String(r.id||""),code:String(r.code||"UNKNOWN_CONFLICT"),message:String(r.message||"Conflict detected"),retryable:!!r.retryable,scope:{tenantId:typeof r.tenantId=="string"?r.tenantId:void 0,branchId:typeof r.branchId=="string"?r.branchId:void 0},details:r}}function P(r){var e;let t=(e=r.branchId)==null?void 0:e.trim();if(!t)throw new m("BRANCH_SCOPE_REQUIRED","Sync scope requires branchId",!1);return t}var b=class{constructor(t){this.options=t}nowIso(){return this.options.nowIso?this.options.nowIso():new Date().toISOString()}async buildHeaders(t){let e={"content-type":"application/json","x-branch-id":P(t)};if(t.tenantId&&(e["x-tenant-id"]=t.tenantId),this.options.getAccessToken){let n=await this.options.getAccessToken();n&&(e.authorization=`Bearer ${n}`)}return e}normalizeUrl(t){return`${this.options.baseUrl.replace(/\/+$/,"")}${t}`}parseFailure(t,e){var o,a,l;let n=e||{},i=((o=n.error)==null?void 0:o.code)||"SYNC_TRANSPORT_ERROR",c=((a=n.error)==null?void 0:a.message)||`Sync transport failed with status ${t}`,s=!!((l=n.error)!=null&&l.retryable);throw new m(i,c,s,t)}async push(t){let e={changes:t.outbox.map(a=>({id:a.change.id,entity:a.change.entity,type:a.change.type,data:{id:a.change.recordId,...a.change.payload},timestamp:a.change.occurredAt}))},n=JSON.stringify(e),i=await R(n),c=await this.buildHeaders(t.scope);c["x-data-checksum"]=i;let s=await this.options.httpAdapter.request({method:"POST",url:this.normalizeUrl("/sync/push"),headers:c,body:e});s.status>=400&&this.parseFailure(s.status,s.data);let o=s.data||{};return{applied:Array.isArray(o.applied)?o.applied:[],failed:Array.isArray(o.failed)?o.failed:[],conflicts:Array.isArray(o.conflicts)?o.conflicts.map(g):[]}}async pull(t){let e=await this.buildHeaders(t.scope),n=t.cursor||this.nowIso(),i=await this.options.httpAdapter.request({method:"GET",url:this.normalizeUrl("/sync/pull"),headers:e,query:{since:n}});i.status>=400&&this.parseFailure(i.status,i.data);let c=i.data||{},s=Array.isArray(c.changes)?c.changes.map(a=>{var l;return{id:String(a.id||""),domain:String(a.domain||"unknown"),entity:String(a.entity||""),type:String(a.type||"UPDATE"),recordId:String(a.recordId||((l=a.record)==null?void 0:l.id)||""),payload:a.record||{},occurredAt:String(a.timestamp||this.nowIso()),scope:t.scope}}):[],o=s.length>0?s[s.length-1].occurredAt:n;return{changes:s,nextCursor:o,conflicts:[]}}async listConflicts(t){let e=await this.buildHeaders(t.scope),n=await this.options.httpAdapter.request({method:"GET",url:this.normalizeUrl("/sync/conflicts"),headers:e});n.status>=400&&this.parseFailure(n.status,n.data);let i=n.data||{};return Array.isArray(i.conflicts)?i.conflicts.map(g):[]}async resolveConflicts(t){var l;let e=await this.listConflicts({scope:t.scope}),n=new Map(e.map(p=>[p.conflictId,p])),i=t.resolutions.map(p=>n.get(p.conflictId)).filter(p=>!!p).map(p=>({table:p.entity,id:p.recordId})),c=await this.buildHeaders(t.scope),s={resolutions:i},o=await this.options.httpAdapter.request({method:"POST",url:this.normalizeUrl("/sync/conflicts/resolve"),headers:c,body:s});o.status>=400&&this.parseFailure(o.status,o.data);let a=Array.isArray((l=o.data)==null?void 0:l.remaining)?o.data.remaining.length:0;return{resolvedCount:Math.max(0,i.length-a)}}};export{d as DEFAULT_SYNC_POLICY,h as InMemoryCheckpointStore,y as InMemoryOutboxStore,_ as OFSYNC_CONTRACT_STAGE,u as OfsyncCore,S as OfsyncCoreBuilder,b as OfsyncHttpTransport,m as OfsyncTransportError,q as createSyncRuntime,f as defaultConflictResolver,G as registerDomainBridge,z as startSync,Y as stopSync};
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";var g=Object.defineProperty;var x=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var R=Object.prototype.hasOwnProperty;var O=(r,t)=>{for(var e in t)g(r,e,{get:t[e],enumerable:!0})},P=(r,t,e,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of A(t))!R.call(r,o)&&o!==e&&g(r,o,{get:()=>t[o],enumerable:!(n=x(t,o))||n.enumerable});return r};var E=r=>P(g({},"__esModule",{value:!0}),r);var U={};O(U,{DEFAULT_SYNC_POLICY:()=>d,InMemoryCheckpointStore:()=>h,InMemoryOutboxStore:()=>y,OFSYNC_CONTRACT_STAGE:()=>k,OfsyncCore:()=>u,OfsyncCoreBuilder:()=>S,OfsyncHttpTransport:()=>C,OfsyncTransportError:()=>m,createSyncRuntime:()=>_,defaultConflictResolver:()=>b,registerDomainBridge:()=>N,startSync:()=>B,stopSync:()=>L});module.exports=E(U);var d={batchSize:200,maxRetry:5,scheduler:{mode:"manual",onlineTrigger:!0},retryBackoff:{strategy:"exponential-jitter",baseDelayMs:500,maxDelayMs:3e4}};var y=class{constructor(){this.rows=new Map}async enqueue(t){this.rows.set(t.outboxId,t)}async listPending(t){return[...this.rows.values()].filter(e=>e.status==="PENDING"||e.status==="FAILED").sort((e,n)=>e.createdAt.localeCompare(n.createdAt)).slice(0,Math.max(0,t))}async findByIdempotencyKey(t){for(let e of this.rows.values())if(e.idempotencyKey===t)return e;return null}async update(t){this.rows.set(t.outboxId,t)}async remove(t){this.rows.delete(t)}};var h=class{constructor(){this.rows=new Map}async get(t){return this.rows.has(t)?this.rows.get(t):null}async set(t,e){this.rows.set(t,e)}};function b(r){return r.code==="VERSION_CONFLICT"?{conflictId:r.conflictId,action:"APPLY_REMOTE"}:r.code==="SCOPE_CONFLICT"?{conflictId:r.conflictId,action:"MANUAL_MERGE"}:{conflictId:r.conflictId,action:"KEEP_LOCAL"}}var k="phase1-runtime-skeleton";var I=require("ofcore"),w=require("ofcore"),f=require("ofcore");function T(r={}){return{...d,...r,scheduler:{...d.scheduler,...r.scheduler||{}},retryBackoff:{...d.retryBackoff,...r.retryBackoff||{}}}}function D(r){return`${r.tenantId||"__standalone__"}::${r.branchId||"__all__"}::${r.deviceId||"__device__"}`}var u=class{constructor(t,e,n={}){this.runtime=t;this.runtimeStateStore=e;this.bridges=new Map;this.runtimeStore=(0,f.asReadonlyStore)(this.runtimeStateStore),this.policy=T(n.policy),this.transport=n.transport,this.outboxStore=n.outboxStore||new y,this.checkpointStore=n.checkpointStore||new h,this.conflictResolvers=n.conflictResolvers||[],this.projectionHook=n.projectionHook}static builder(){return new S}get registry(){return this.runtime.registry}getPolicy(){return this.policy}registerDomainBridge(t){let e=t.domain.trim();if(!e)throw new Error("DomainBridge.domain is required");if(this.bridges.has(e))throw new Error(`DomainBridge for domain "${e}" is already registered`);this.bridges.set(e,t)}listDomainBridges(){return[...this.bridges.keys()].sort()}async enqueueLocalChange(t,e){let n=(e||`${t.domain}:${t.entity}:${t.recordId}:${t.type}:${t.id}`).trim();if(!n)throw new Error("idempotencyKey must not be empty");let o=await this.outboxStore.findByIdempotencyKey(n);if(o)return o;let c=new Date().toISOString(),s={outboxId:`outbox_${Date.now()}_${Math.random().toString(36).slice(2)}`,idempotencyKey:n,change:t,retryCount:0,status:"PENDING",createdAt:c,updatedAt:c};return await this.outboxStore.enqueue(s),s}async start(t={}){this.runtimeStateStore.setState({phase:"starting",started:!1,syncing:!1,lastError:null,lastTransitionAt:new Date().toISOString()});try{await this.runtime.start(t),this.runtimeStateStore.setState(e=>({...e,phase:"started",started:!0,syncing:!1,startCount:e.startCount+1,lastError:null,lastTransitionAt:new Date().toISOString()}))}catch(e){throw this.runtimeStateStore.setState({phase:"error",started:!1,syncing:!1,lastError:e instanceof Error?e.message:String(e),lastTransitionAt:new Date().toISOString()}),e}}async stop(){this.stopScheduler(),this.runtimeStateStore.setState({phase:"stopping",started:this.runtime.isStarted(),syncing:!1,lastError:null,lastTransitionAt:new Date().toISOString()});try{await this.runtime.stop(),this.runtimeStateStore.setState(t=>({...t,phase:"stopped",started:!1,syncing:!1,stopCount:t.stopCount+1,lastError:null,lastTransitionAt:new Date().toISOString()}))}catch(t){throw this.runtimeStateStore.setState({phase:"error",started:this.runtime.isStarted(),syncing:!1,lastError:t instanceof Error?t.message:String(t),lastTransitionAt:new Date().toISOString()}),t}}async startSync(t){if(!this.runtime.isStarted())throw new Error("Cannot start sync before runtime is started");this.runtimeStateStore.setState({phase:"syncing",started:!0,syncing:!0,lastError:null,lastTransitionAt:new Date().toISOString()});try{let e=D(t),n=await this.checkpointStore.get(e);if(this.transport){await this.processOutbox(t);let o=await this.transport.pull({scope:t,cursor:n,limit:this.policy.batchSize});await this.applyRemoteChanges(o.changes||[],t),o.nextCursor!==void 0&&await this.checkpointStore.set(e,o.nextCursor)}this.runtimeStateStore.setState(o=>({...o,phase:"started",started:!0,syncing:!1,syncCount:o.syncCount+1,lastError:null,lastTransitionAt:new Date().toISOString()}))}catch(e){throw this.runtimeStateStore.setState({phase:"error",started:this.runtime.isStarted(),syncing:!1,lastError:e instanceof Error?e.message:String(e),lastTransitionAt:new Date().toISOString()}),e}}async stopSync(){this.runtimeStateStore.setState(t=>({...t,phase:this.runtime.isStarted()?"started":"stopped",syncing:!1,lastTransitionAt:new Date().toISOString()}))}isStarted(){return this.runtime.isStarted()}startScheduler(t){if(this.policy.scheduler.mode!=="interval")return;let e=this.policy.scheduler.intervalMs||1e4;this.stopScheduler(),this.schedulerTimer=setInterval(()=>{this.startSync(t).catch(n=>{this.runtimeStateStore.setState(o=>({...o,phase:"error",syncing:!1,lastError:n instanceof Error?n.message:String(n),lastTransitionAt:new Date().toISOString()}))})},e)}stopScheduler(){this.schedulerTimer&&(clearInterval(this.schedulerTimer),this.schedulerTimer=void 0)}async notifyOnline(t){this.policy.scheduler.onlineTrigger!==!1&&await this.startSync(t)}async processOutbox(t){if(!this.transport)return;let e=await this.outboxStore.listPending(this.policy.batchSize);if(e.length===0)return;let n=await this.transport.push({scope:t,outbox:e}),o=new Set((n.applied||[]).map(i=>i.changeId)),c=new Map((n.failed||[]).map(i=>[i.changeId,i])),s=new Set((n.conflicts||[]).map(i=>i.changeId));await this.resolveConflicts(n.conflicts||[],t);for(let i of e){if(o.has(i.change.id)){await this.outboxStore.remove(i.outboxId);continue}let a=c.get(i.change.id);if(a||s.has(i.change.id)){let l=i.retryCount+1;await this.outboxStore.update({...i,retryCount:l,status:l>=this.policy.maxRetry?"FAILED":"PENDING",lastError:(a==null?void 0:a.message)||(s.has(i.change.id)?"SYNC_CONFLICT":i.lastError),updatedAt:new Date().toISOString()})}}}async applyRemoteChanges(t,e){if(!Array.isArray(t)||t.length===0)return;let n=[...t].sort((c,s)=>c.occurredAt.localeCompare(s.occurredAt)),o=new Map;for(let c of n){o.has(c.domain)||o.set(c.domain,[]);let s=o.get(c.domain);s&&s.push(c)}for(let[c,s]of o.entries()){let i=this.bridges.get(c);if(i!=null&&i.applyRemoteChanges&&(await i.applyRemoteChanges(s,e),this.projectionHook))for(let a of s)await this.projectionHook(a,e)}}async resolveConflicts(t,e){var o;if(!((o=this.transport)!=null&&o.resolveConflicts)||t.length===0)return;let n=[];for(let c of t){let s=[...this.conflictResolvers],i=null;for(let a of s){let l=await a(c,{scope:e});if(l){i=l;break}}i||(i=b(c)),n.push(i)}n.length>0&&await this.transport.resolveConflicts({scope:e,resolutions:n})}},S=class{constructor(){this.runtimeBuilder=I.CoreRuntime.builder();this.options={};this.runtimeBuilder.withDbAdapter(()=>new w.InMemoryDbAdapter)}withPlatformAdapter(t){return this.runtimeBuilder.withPlatformAdapter(t),this}withDbAdapter(t){return this.runtimeBuilder.withDbAdapter(t),this}withHttpAdapter(t){return this.runtimeBuilder.withHttpAdapter(t),this}withSocketAdapter(t){return this.runtimeBuilder.withSocketAdapter(t),this}withLoggerAdapter(t){return this.runtimeBuilder.withLoggerAdapter(t),this}withActivitySink(t){return this.runtimeBuilder.withActivitySink(t),this}withExtension(t,e){return this.runtimeBuilder.withExtension(t,e),this}withPolicy(t){return this.options={...this.options,policy:t},this}withTransport(t){return this.options={...this.options,transport:t},this}withOutboxStore(t){return this.options={...this.options,outboxStore:t},this}withCheckpointStore(t){return this.options={...this.options,checkpointStore:t},this}withConflictResolvers(t){return this.options={...this.options,conflictResolvers:t},this}withProjectionHook(t){return this.options={...this.options,projectionHook:t},this}build(){let t=(0,f.createStore)({phase:"idle",started:!1,syncing:!1,startCount:0,stopCount:0,syncCount:0,lastError:null,lastTransitionAt:new Date().toISOString()}),e=this.runtimeBuilder.build();return new u(e,t,this.options)}};function _(r={}){let t=u.builder();return r.policy&&t.withPolicy(r.policy),r.transport&&t.withTransport(r.transport),r.outboxStore&&t.withOutboxStore(r.outboxStore),r.checkpointStore&&t.withCheckpointStore(r.checkpointStore),r.conflictResolvers&&t.withConflictResolvers(r.conflictResolvers),r.projectionHook&&t.withProjectionHook(r.projectionHook),t.build()}function N(r,t){r.registerDomainBridge(t)}async function B(r,t,e={}){r.isStarted()||await r.start(e),await r.startSync(t)}async function L(r){await r.stopSync()}var m=class extends Error{constructor(t,e,n=!1,o){super(e),this.name="OfsyncTransportError",this.code=t,this.retryable=n,this.status=o}};async function M(r){let t=globalThis.crypto;if(!t||!t.subtle)throw new Error("WebCrypto API is not available");let e=new TextEncoder().encode(r),n=await t.subtle.digest("SHA-256",e);return[...new Uint8Array(n)].map(o=>o.toString(16).padStart(2,"0")).join("")}function H(r){let t=String(r.table||""),e=String(r.id||""),n=String(r.changeId||"");return[t,e,n].join("::")}function v(r){return{conflictId:H(r),changeId:String(r.changeId||""),domain:"unknown",entity:String(r.table||""),recordId:String(r.id||""),code:String(r.code||"UNKNOWN_CONFLICT"),message:String(r.message||"Conflict detected"),retryable:!!r.retryable,scope:{tenantId:typeof r.tenantId=="string"?r.tenantId:void 0,branchId:typeof r.branchId=="string"?r.branchId:void 0},details:r}}function F(r){var e;let t=(e=r.branchId)==null?void 0:e.trim();if(!t)throw new m("BRANCH_SCOPE_REQUIRED","Sync scope requires branchId",!1);return t}var C=class{constructor(t){this.options=t}nowIso(){return this.options.nowIso?this.options.nowIso():new Date().toISOString()}async buildHeaders(t){let e={"content-type":"application/json","x-branch-id":F(t)};if(t.tenantId&&(e["x-tenant-id"]=t.tenantId),this.options.getAccessToken){let n=await this.options.getAccessToken();n&&(e.authorization=`Bearer ${n}`)}return e}normalizeUrl(t){return`${this.options.baseUrl.replace(/\/+$/,"")}${t}`}parseFailure(t,e){var i,a,l;let n=e||{},o=((i=n.error)==null?void 0:i.code)||"SYNC_TRANSPORT_ERROR",c=((a=n.error)==null?void 0:a.message)||`Sync transport failed with status ${t}`,s=!!((l=n.error)!=null&&l.retryable);throw new m(o,c,s,t)}async push(t){let e={changes:t.outbox.map(a=>({id:a.change.id,entity:a.change.entity,type:a.change.type,data:{id:a.change.recordId,...a.change.payload},timestamp:a.change.occurredAt}))},n=JSON.stringify(e),o=await M(n),c=await this.buildHeaders(t.scope);c["x-data-checksum"]=o;let s=await this.options.httpAdapter.request({method:"POST",url:this.normalizeUrl("/sync/push"),headers:c,body:e});s.status>=400&&this.parseFailure(s.status,s.data);let i=s.data||{};return{applied:Array.isArray(i.applied)?i.applied:[],failed:Array.isArray(i.failed)?i.failed:[],conflicts:Array.isArray(i.conflicts)?i.conflicts.map(v):[]}}async pull(t){let e=await this.buildHeaders(t.scope),n=t.cursor||this.nowIso(),o=await this.options.httpAdapter.request({method:"GET",url:this.normalizeUrl("/sync/pull"),headers:e,query:{since:n}});o.status>=400&&this.parseFailure(o.status,o.data);let c=o.data||{},s=Array.isArray(c.changes)?c.changes.map(a=>{var l;return{id:String(a.id||""),domain:String(a.domain||"unknown"),entity:String(a.entity||""),type:String(a.type||"UPDATE"),recordId:String(a.recordId||((l=a.record)==null?void 0:l.id)||""),payload:a.record||{},occurredAt:String(a.timestamp||this.nowIso()),scope:t.scope}}):[],i=s.length>0?s[s.length-1].occurredAt:n;return{changes:s,nextCursor:i,conflicts:[]}}async listConflicts(t){let e=await this.buildHeaders(t.scope),n=await this.options.httpAdapter.request({method:"GET",url:this.normalizeUrl("/sync/conflicts"),headers:e});n.status>=400&&this.parseFailure(n.status,n.data);let o=n.data||{};return Array.isArray(o.conflicts)?o.conflicts.map(v):[]}async resolveConflicts(t){var l;let e=await this.listConflicts({scope:t.scope}),n=new Map(e.map(p=>[p.conflictId,p])),o=t.resolutions.map(p=>n.get(p.conflictId)).filter(p=>!!p).map(p=>({table:p.entity,id:p.recordId})),c=await this.buildHeaders(t.scope),s={resolutions:o},i=await this.options.httpAdapter.request({method:"POST",url:this.normalizeUrl("/sync/conflicts/resolve"),headers:c,body:s});i.status>=400&&this.parseFailure(i.status,i.data);let a=Array.isArray((l=i.data)==null?void 0:l.remaining)?i.data.remaining.length:0;return{resolvedCount:Math.max(0,o.length-a)}}};
@@ -0,0 +1,2 @@
1
+ export type OfsyncContractStage = 'phase1-runtime-skeleton';
2
+ export declare const OFSYNC_CONTRACT_STAGE: OfsyncContractStage;
@@ -0,0 +1,11 @@
1
+ import type { ConflictItem, SyncScope } from '../contracts/SyncContract';
2
+ export type ConflictResolutionAction = 'APPLY_REMOTE' | 'KEEP_LOCAL' | 'MANUAL_MERGE';
3
+ export interface ConflictResolutionDecision {
4
+ conflictId: string;
5
+ action: ConflictResolutionAction;
6
+ }
7
+ export interface ConflictResolverContext {
8
+ scope: SyncScope;
9
+ }
10
+ export type ConflictResolver = (conflict: ConflictItem, context: ConflictResolverContext) => Promise<ConflictResolutionDecision | null> | ConflictResolutionDecision | null;
11
+ export declare function defaultConflictResolver(conflict: ConflictItem): ConflictResolutionDecision;
@@ -0,0 +1,6 @@
1
+ import type { SyncChange, SyncScope } from '../contracts/SyncContract';
2
+ export interface DomainBridge {
3
+ domain: string;
4
+ collectLocalChanges?: (scope: SyncScope) => Promise<SyncChange[]>;
5
+ applyRemoteChanges?: (changes: SyncChange[], scope: SyncScope) => Promise<void>;
6
+ }
@@ -0,0 +1,9 @@
1
+ export interface CheckpointStore {
2
+ get(scopeKey: string): Promise<string | null>;
3
+ set(scopeKey: string, value: string | null): Promise<void>;
4
+ }
5
+ export declare class InMemoryCheckpointStore implements CheckpointStore {
6
+ private readonly rows;
7
+ get(scopeKey: string): Promise<string | null>;
8
+ set(scopeKey: string, value: string | null): Promise<void>;
9
+ }
@@ -0,0 +1,16 @@
1
+ import type { OutboxItem } from '../contracts/SyncContract';
2
+ export interface OutboxStore {
3
+ enqueue(item: OutboxItem): Promise<void>;
4
+ listPending(limit: number): Promise<OutboxItem[]>;
5
+ findByIdempotencyKey(idempotencyKey: string): Promise<OutboxItem | null>;
6
+ update(item: OutboxItem): Promise<void>;
7
+ remove(outboxId: string): Promise<void>;
8
+ }
9
+ export declare class InMemoryOutboxStore implements OutboxStore {
10
+ private readonly rows;
11
+ enqueue(item: OutboxItem): Promise<void>;
12
+ listPending(limit: number): Promise<OutboxItem[]>;
13
+ findByIdempotencyKey(idempotencyKey: string): Promise<OutboxItem | null>;
14
+ update(item: OutboxItem): Promise<void>;
15
+ remove(outboxId: string): Promise<void>;
16
+ }
@@ -0,0 +1,42 @@
1
+ import type { ConflictItem, OutboxItem, SyncChange, SyncScope } from '../contracts/SyncContract';
2
+ export interface SyncPushResult {
3
+ applied: Array<{
4
+ changeId: string;
5
+ status: 'OK' | 'ALREADY_PROCESSED';
6
+ }>;
7
+ failed: Array<{
8
+ changeId: string;
9
+ code: string;
10
+ message: string;
11
+ retryable: boolean;
12
+ }>;
13
+ conflicts: ConflictItem[];
14
+ }
15
+ export interface SyncPullResult {
16
+ changes: SyncChange[];
17
+ nextCursor: string | null;
18
+ conflicts?: ConflictItem[];
19
+ }
20
+ export interface SyncTransport {
21
+ push(params: {
22
+ scope: SyncScope;
23
+ outbox: OutboxItem[];
24
+ }): Promise<SyncPushResult>;
25
+ pull(params: {
26
+ scope: SyncScope;
27
+ cursor: string | null;
28
+ limit: number;
29
+ }): Promise<SyncPullResult>;
30
+ listConflicts?(params: {
31
+ scope: SyncScope;
32
+ }): Promise<ConflictItem[]>;
33
+ resolveConflicts?(params: {
34
+ scope: SyncScope;
35
+ resolutions: Array<{
36
+ conflictId: string;
37
+ action: 'APPLY_REMOTE' | 'KEEP_LOCAL' | 'MANUAL_MERGE';
38
+ }>;
39
+ }): Promise<{
40
+ resolvedCount: number;
41
+ }>;
42
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "ofsync-shared-core",
3
+ "version": "0.2.0-alpha.0",
4
+ "private": false,
5
+ "description": "Host-side offline-first sync orchestration core for multi-domain of* apps.",
6
+ "author": {
7
+ "name": "Agus Made",
8
+ "email": "krisnaparta@gmail.com"
9
+ },
10
+ "main": "dist/index.js",
11
+ "module": "dist/index.esm.js",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.esm.js",
17
+ "require": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "scripts": {
24
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
25
+ "build": "npm run clean && tsc -p tsconfig.build.json && node esbuild.config.js",
26
+ "typecheck": "tsc -p tsconfig.json --noEmit",
27
+ "test": "NODE_PATH=.. npm run build && NODE_PATH=.. node --test ./tests/*.test.js",
28
+ "verify:contract": "node ./scripts/verify-surface.js",
29
+ "verify:surface": "node ./scripts/verify-surface.js",
30
+ "verify:boundary": "node ./scripts/verify-boundary-imports.js",
31
+ "verify:no-env": "node ./scripts/verify-no-env-coupling.js",
32
+ "ci:check": "npm run typecheck && npm run verify:contract && npm run verify:boundary && npm run verify:no-env && npm run test",
33
+ "prepublishOnly": "npm run ci:check",
34
+ "prepack": "npm run build",
35
+ "bundle": "node esbuild.config.js"
36
+ },
37
+ "dependencies": {
38
+ "ofcore": "0.1.0-alpha.0"
39
+ },
40
+ "devDependencies": {
41
+ "esbuild": "^0.25.11",
42
+ "typescript": "^5.9.3"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }