@abloatai/transaction 0.59.0 → 0.59.2
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/dist/auth/capability.d.ts +15 -15
- package/dist/auth/capabilityLifecycle.d.ts +2 -2
- package/dist/auth/credentialEndpoint.js.map +1 -1
- package/dist/auth/credentialSource.js.map +1 -1
- package/dist/auth/identity.js.map +1 -1
- package/dist/auth/schemas.d.ts +5 -5
- package/dist/auth/sessionIssuanceProtocol.d.ts +8 -8
- package/dist/auth/sessionIssuanceProtocol.d.ts.map +1 -1
- package/dist/branches.d.ts +38 -38
- package/dist/claims/contract.d.ts +27 -27
- package/dist/claims/eventContract.d.ts +18 -18
- package/dist/claims/routes.d.ts +9 -9
- package/dist/claims/routes.d.ts.map +1 -1
- package/dist/claims/trace.d.ts.map +1 -1
- package/dist/claims/trace.js.map +1 -1
- package/dist/client/ablo.d.ts +6 -5
- package/dist/client/ablo.d.ts.map +1 -1
- package/dist/client/ablo.js +0 -8
- package/dist/client/ablo.js.map +1 -1
- package/dist/client/resources/httpResources.js.map +1 -1
- package/dist/client/resources/modelOperations.d.ts +6 -6
- package/dist/client/resources/modelOperations.d.ts.map +1 -1
- package/dist/client/resources/where.d.ts +12 -12
- package/dist/client/surface.d.ts +2 -2
- package/dist/client/surface.d.ts.map +1 -1
- package/dist/client/surface.js +7 -0
- package/dist/client/surface.js.map +1 -1
- package/dist/commit/confirmation/commitEnvelope.d.ts +7 -7
- package/dist/commit/confirmation/httpCommitEnvelope.d.ts +1 -1
- package/dist/commit/confirmation/pendingWrite.d.ts +5 -5
- package/dist/commit/contract.d.ts +249 -249
- package/dist/commit/contract.d.ts.map +1 -1
- package/dist/commit/recordRuntime.d.ts +3 -2
- package/dist/commit/recordRuntime.d.ts.map +1 -1
- package/dist/commit/recordRuntime.js +1 -0
- package/dist/commit/recordRuntime.js.map +1 -1
- package/dist/coordination/schema.d.ts +54 -54
- package/dist/coordination/schema.js.map +1 -1
- package/dist/environment.d.ts +3 -3
- package/dist/environment.d.ts.map +1 -1
- package/dist/environment.js.map +1 -1
- package/dist/errorCodes.d.ts +6 -5
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +2 -1
- package/dist/errorCodes.js.map +1 -1
- package/dist/errorObservation.d.ts +14 -14
- package/dist/errors.d.ts +11 -11
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +16 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keys/index.d.ts +1 -1
- package/dist/keys/index.d.ts.map +1 -1
- package/dist/keys/index.js.map +1 -1
- package/dist/observation/contract.d.ts +34 -34
- package/dist/observation/feedContract.d.ts +86 -86
- package/dist/observation/persistence/syncDeltaRow.d.ts +23 -23
- package/dist/observation/persistence/syncDeltaRow.d.ts.map +1 -1
- package/dist/pricing.d.ts +12 -12
- package/dist/schema/audit.js.map +1 -1
- package/dist/schema/ddl.js.map +1 -1
- package/dist/schema/ddlLock.js.map +1 -1
- package/dist/schema/deployment/contracts.d.ts +64 -64
- package/dist/schema/deployment/index.d.ts.map +1 -1
- package/dist/schema/deployment/index.js +52 -3
- package/dist/schema/deployment/index.js.map +1 -1
- package/dist/schema/deployment/reconcile.d.ts +2 -2
- package/dist/schema/deployment/reconcile.d.ts.map +1 -1
- package/dist/schema/deployment/reconcile.js +98 -6
- package/dist/schema/deployment/reconcile.js.map +1 -1
- package/dist/schema/diff.js.map +1 -1
- package/dist/schema/field.d.ts +1 -1
- package/dist/schema/field.d.ts.map +1 -1
- package/dist/schema/loadStrategy.d.ts +2 -2
- package/dist/schema/loadStrategy.d.ts.map +1 -1
- package/dist/schema/openapi.js.map +1 -1
- package/dist/schema/relation.d.ts +3 -3
- package/dist/schema/relation.d.ts.map +1 -1
- package/dist/schema/residency.d.ts +1 -1
- package/dist/schema/roles.d.ts +1 -1
- package/dist/schema/roles.d.ts.map +1 -1
- package/dist/schema/roles.js.map +1 -1
- package/dist/schema/schema.d.ts +1 -1
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/serialize.js.map +1 -1
- package/dist/schema/sugar.d.ts.map +1 -1
- package/dist/schema/tenancy.d.ts +2 -2
- package/dist/server/storageMode.d.ts +1 -1
- package/dist/source/adapters/adapterProfile.d.ts +6 -6
- package/dist/source/adapters/conformance.js.map +1 -1
- package/dist/source/adapters/contract.d.ts +14 -14
- package/dist/source/adapters/kysely.js.map +1 -1
- package/dist/source/adapters/memory.js.map +1 -1
- package/dist/source/adapters/prisma.js.map +1 -1
- package/dist/source/connector/client.d.ts.map +1 -1
- package/dist/source/connector/client.js.map +1 -1
- package/dist/source/connector/protocol.d.ts.map +1 -1
- package/dist/source/delivery/pushQueue.d.ts.map +1 -1
- package/dist/source/delivery/pushQueue.js.map +1 -1
- package/dist/source/endpoint/signing.d.ts +4 -4
- package/dist/source/endpoint/signing.d.ts.map +1 -1
- package/dist/source/outbox/contract.d.ts +4 -4
- package/dist/testing/fixtures/httpResponses.d.ts +2 -2
- package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
- package/dist/testing/fixtures/httpResponses.js.map +1 -1
- package/dist/transport/connection/credentialLifecycle.d.ts.map +1 -1
- package/dist/transport/connection/credentialLifecycle.js.map +1 -1
- package/dist/transport/connection/manager.d.ts.map +1 -1
- package/dist/transport/connection/manager.js.map +1 -1
- package/dist/transport/connection/networkProbe.d.ts +18 -5
- package/dist/transport/connection/networkProbe.d.ts.map +1 -1
- package/dist/transport/connection/networkProbe.js.map +1 -1
- package/dist/transport/http/client.d.ts +37 -1
- package/dist/transport/http/client.d.ts.map +1 -1
- package/dist/transport/http/client.js +117 -2
- package/dist/transport/http/client.js.map +1 -1
- package/dist/transport/http/transport.d.ts +21 -0
- package/dist/transport/http/transport.d.ts.map +1 -1
- package/dist/transport/http/transport.js +93 -5
- package/dist/transport/http/transport.js.map +1 -1
- package/dist/transport/websocket/commitFrames.d.ts +4 -3
- package/dist/transport/websocket/commitFrames.d.ts.map +1 -1
- package/dist/transport/websocket/commitFrames.js +1 -0
- package/dist/transport/websocket/commitFrames.js.map +1 -1
- package/dist/transport/websocket/contextSubscription.d.ts +6 -0
- package/dist/transport/websocket/contextSubscription.d.ts.map +1 -0
- package/dist/transport/websocket/contextSubscription.js +45 -0
- package/dist/transport/websocket/contextSubscription.js.map +1 -0
- package/dist/transport/websocket/heartbeat.d.ts.map +1 -1
- package/dist/transport/websocket/heartbeat.js.map +1 -1
- package/dist/transport/websocket/session.d.ts +4 -0
- package/dist/transport/websocket/session.d.ts.map +1 -0
- package/dist/transport/websocket/session.js +385 -0
- package/dist/transport/websocket/session.js.map +1 -0
- package/dist/transport/websocket/sessionContract.d.ts +89 -0
- package/dist/transport/websocket/sessionContract.d.ts.map +1 -0
- package/dist/transport/websocket/sessionContract.js +2 -0
- package/dist/transport/websocket/sessionContract.js.map +1 -0
- package/dist/transport/websocket/transport.d.ts +22 -0
- package/dist/transport/websocket/transport.d.ts.map +1 -1
- package/dist/transport/websocket/transport.js +63 -1
- package/dist/transport/websocket/transport.js.map +1 -1
- package/dist/wire/accountResponses.d.ts +31 -31
- package/dist/wire/auth.d.ts +3 -3
- package/dist/wire/bootstrapReason.d.ts +2 -2
- package/dist/wire/dataSourceResponses.d.ts +2 -2
- package/dist/wire/dataSourceResponses.d.ts.map +1 -1
- package/dist/wire/frames.d.ts +8 -8
- package/dist/wire/inboundFrames.d.ts +56 -56
- package/dist/wire/inboundFrames.d.ts.map +1 -1
- package/dist/wire/listEnvelope.js.map +1 -1
- package/dist/wire/modelResponses.d.ts +2 -2
- package/dist/wire/modelShape.d.ts +4 -4
- package/dist/wire/pricing.d.ts +3 -3
- package/dist/wire/protocolVersion.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/client/ablo.ts +10 -5
- package/src/client/surface.ts +8 -1
- package/src/commit/recordRuntime.ts +4 -2
- package/src/errorCodes.ts +5 -1
- package/src/errors.ts +18 -1
- package/src/index.ts +1 -0
- package/src/schema/deployment/index.ts +54 -3
- package/src/schema/deployment/reconcile.ts +122 -6
- package/src/transport/http/client.ts +171 -4
- package/src/transport/http/transport.ts +126 -4
- package/src/transport/websocket/commitFrames.ts +5 -3
- package/src/transport/websocket/contextSubscription.ts +55 -0
- package/src/transport/websocket/session.ts +467 -0
- package/src/transport/websocket/sessionContract.ts +115 -0
- package/src/transport/websocket/transport.ts +72 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DeploymentApplyResult, DeploymentObservation, DeploymentStep, SchemaDeploymentPlan } from './contracts.js';
|
|
1
|
+
import type { DeploymentApplyResult, DeploymentFinding, DeploymentObservation, DeploymentStep, SchemaDeploymentPlan } from './contracts.js';
|
|
2
2
|
import { deploymentFingerprint } from './fingerprint.js';
|
|
3
3
|
import { reconcileDeploymentManifest, reconcilePolicyIntent, reconcileSchemaToDatabase, reconcileSourceToActiveResult } from './reconcile.js';
|
|
4
4
|
import { sequenceDeployment } from './sequence.js';
|
|
@@ -10,6 +10,27 @@ export { sequenceDeployment } from './sequence.js';
|
|
|
10
10
|
export * from './backfill.js';
|
|
11
11
|
export * from './postgresCatalog.js';
|
|
12
12
|
|
|
13
|
+
function physicalEvidenceKey(finding: DeploymentFinding): string {
|
|
14
|
+
return JSON.stringify([
|
|
15
|
+
finding.code,
|
|
16
|
+
finding.category,
|
|
17
|
+
finding.severity,
|
|
18
|
+
finding.phase,
|
|
19
|
+
finding.owner,
|
|
20
|
+
finding.model,
|
|
21
|
+
finding.field,
|
|
22
|
+
finding.column,
|
|
23
|
+
finding.from,
|
|
24
|
+
finding.to,
|
|
25
|
+
finding.message,
|
|
26
|
+
finding.action,
|
|
27
|
+
]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function physicalResourceKey(finding: DeploymentFinding): string {
|
|
31
|
+
return JSON.stringify([finding.model, finding.column, finding.field]);
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
function planStates(observation: DeploymentObservation): SchemaDeploymentPlan['states'] {
|
|
14
35
|
const { schema: _sourceSchema, ...source } = observation.source;
|
|
15
36
|
const active = observation.active ? (({ schema: _activeSchema, ...state }) => state)(observation.active) : null;
|
|
@@ -24,14 +45,44 @@ export function buildSchemaDeploymentPlan(observation: DeploymentObservation, no
|
|
|
24
45
|
observation.source.schema,
|
|
25
46
|
observation.intent?.renames,
|
|
26
47
|
observation.intent?.backfills,
|
|
48
|
+
observation.database,
|
|
27
49
|
);
|
|
28
50
|
const provision = reconcileSourceToActiveResult(null, observation.source.schema).operations;
|
|
51
|
+
const sourceToDatabase = reconcileSchemaToDatabase(observation.source.schema, observation.database, 'source_to_database');
|
|
52
|
+
const sourcePhysicalEvidence = new Set(sourceToDatabase.map(physicalEvidenceKey));
|
|
53
|
+
const sourcePhysicalResources = new Set(sourceToDatabase.map(physicalResourceKey));
|
|
54
|
+
const activeDatabaseFindings = observation.active
|
|
55
|
+
? reconcileSchemaToDatabase(observation.active.schema, observation.database, 'active_to_database')
|
|
56
|
+
: [];
|
|
57
|
+
const candidateAlignedActiveFindings: DeploymentFinding[] = [];
|
|
58
|
+
const activeToDatabase = activeDatabaseFindings.filter((finding) => {
|
|
59
|
+
if (sourcePhysicalEvidence.has(physicalEvidenceKey(finding))) return false;
|
|
60
|
+
if (!sourcePhysicalResources.has(physicalResourceKey(finding))) {
|
|
61
|
+
candidateAlignedActiveFindings.push(finding);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
const candidateAlignmentEvidence: DeploymentFinding[] = candidateAlignedActiveFindings.length === 0 ? [] : [{
|
|
67
|
+
id: 'active_to_database:candidate_alignment_verified',
|
|
68
|
+
code: 'candidate_alignment_verified',
|
|
69
|
+
category: 'observation',
|
|
70
|
+
severity: 'info',
|
|
71
|
+
direction: 'active_to_database',
|
|
72
|
+
phase: 'verify',
|
|
73
|
+
owner: observation.database?.ownership === 'ablo' ? 'ablo' : 'application_migration',
|
|
74
|
+
from: { activePhysicalDifferences: candidateAlignedActiveFindings.length },
|
|
75
|
+
to: 'source_database_alignment',
|
|
76
|
+
message: `PostgreSQL differs from the active artifact at ${candidateAlignedActiveFindings.length} physical contract location(s), and the candidate source already matches those locations.`,
|
|
77
|
+
action: 'Treat these differences as completed expand work; review the remaining source-to-active compatibility findings before activation.',
|
|
78
|
+
}];
|
|
29
79
|
const findings = [
|
|
30
80
|
...reconcilePolicyIntent(observation.source.schema),
|
|
31
81
|
...reconcileDeploymentManifest(observation.intent?.manifest),
|
|
32
82
|
...sourceToActive.findings,
|
|
33
|
-
...
|
|
34
|
-
...
|
|
83
|
+
...sourceToDatabase,
|
|
84
|
+
...activeToDatabase,
|
|
85
|
+
...candidateAlignmentEvidence,
|
|
35
86
|
...(observation.supplementalFindings ?? []),
|
|
36
87
|
];
|
|
37
88
|
const unique = [...new Map(findings.map((finding) => [finding.id, finding])).values()].map((finding) =>
|
|
@@ -49,15 +49,89 @@ function stepFinding(step: MigrationStep): DeploymentFinding | null {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
function databaseSatisfiesRequiredField(
|
|
53
|
+
signal: MigrationSignal,
|
|
54
|
+
source: SchemaJSON,
|
|
55
|
+
database: DatabaseSnapshot | null,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (!database || (signal.code !== 'required_field_added' && signal.code !== 'made_required') || !signal.field) return false;
|
|
58
|
+
const model = source.models[signal.model];
|
|
59
|
+
const field = model?.fields[signal.field];
|
|
60
|
+
if (!model || !field || field.isOptional) return false;
|
|
61
|
+
const table = database.tables[model.tableName ?? signal.model];
|
|
62
|
+
const column = table?.columns[field.column ?? camelToSnake(signal.field)];
|
|
63
|
+
return column !== undefined &&
|
|
64
|
+
!column.nullable &&
|
|
65
|
+
(column.nullCount === undefined || column.nullCount === null || column.nullCount === 0) &&
|
|
66
|
+
normalizePgType(column.dataType) === sqlType(field.type);
|
|
54
67
|
}
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
function databaseSatisfiesRiskyCast(
|
|
70
|
+
signal: MigrationSignal,
|
|
71
|
+
source: SchemaJSON,
|
|
72
|
+
database: DatabaseSnapshot | null,
|
|
73
|
+
): boolean {
|
|
74
|
+
if (!database || signal.code !== 'risky_cast' || !signal.field) return false;
|
|
75
|
+
const model = source.models[signal.model];
|
|
76
|
+
const field = model?.fields[signal.field];
|
|
77
|
+
// A TEXT column does not prove that existing rows satisfy a newly narrowed
|
|
78
|
+
// enum CHECK constraint; the catalog snapshot currently observes type only.
|
|
79
|
+
if (!model || !field || field.type === 'enum') return false;
|
|
80
|
+
const table = database.tables[model.tableName ?? signal.model];
|
|
81
|
+
const column = table?.columns[field.column ?? camelToSnake(signal.field)];
|
|
82
|
+
return column !== undefined && normalizePgType(column.dataType) === sqlType(field.type);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function databasePreservesRemovedApplicationModel(
|
|
86
|
+
signal: MigrationSignal,
|
|
87
|
+
active: SchemaJSON | null,
|
|
88
|
+
source: SchemaJSON,
|
|
89
|
+
database: DatabaseSnapshot | null,
|
|
90
|
+
): boolean {
|
|
91
|
+
if (!active || !database || database.ownership !== 'application' || signal.code !== 'drop_model') return false;
|
|
92
|
+
if (source.models[signal.model]) return false;
|
|
93
|
+
const activeModel = active.models[signal.model];
|
|
94
|
+
return activeModel !== undefined && database.tables[activeModel.tableName ?? signal.model] !== undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function reconcileSourceToActive(
|
|
98
|
+
active: SchemaJSON | null,
|
|
99
|
+
source: SchemaJSON,
|
|
100
|
+
hints: RenameHints = {},
|
|
101
|
+
backfills: readonly BackfillValue[] = [],
|
|
102
|
+
database: DatabaseSnapshot | null = null,
|
|
103
|
+
): readonly DeploymentFinding[] {
|
|
104
|
+
return reconcileSourceToActiveResult(active, source, hints, backfills, database).findings;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function reconcileSourceToActiveResult(
|
|
108
|
+
active: SchemaJSON | null,
|
|
109
|
+
source: SchemaJSON,
|
|
110
|
+
hints: RenameHints = {},
|
|
111
|
+
backfills: readonly BackfillValue[] = [],
|
|
112
|
+
database: DatabaseSnapshot | null = null,
|
|
113
|
+
): { operations: readonly MigrationStep[]; findings: readonly DeploymentFinding[] } {
|
|
57
114
|
const steps = diffSchema(active, source, hints);
|
|
58
115
|
const classification = classifyMigration(steps);
|
|
59
|
-
const
|
|
60
|
-
const
|
|
116
|
+
const blockers = unresolvedBlockers(classification, backfills);
|
|
117
|
+
const requiredFieldsVerified = blockers.filter((signal) => databaseSatisfiesRequiredField(signal, source, database));
|
|
118
|
+
const typeCorrectionsVerified = classification.warnings.filter((signal) => databaseSatisfiesRiskyCast(signal, source, database));
|
|
119
|
+
const removedApplicationModelsPreserved = classification.warnings.filter((signal) =>
|
|
120
|
+
databasePreservesRemovedApplicationModel(signal, active, source, database)
|
|
121
|
+
);
|
|
122
|
+
const databaseVerified = new Set<MigrationSignal>([
|
|
123
|
+
...requiredFieldsVerified,
|
|
124
|
+
...typeCorrectionsVerified,
|
|
125
|
+
...removedApplicationModelsPreserved,
|
|
126
|
+
]);
|
|
127
|
+
const signals = [
|
|
128
|
+
...blockers.filter((signal) => !databaseVerified.has(signal)),
|
|
129
|
+
...classification.warnings.filter((signal) => !databaseVerified.has(signal)),
|
|
130
|
+
].map((signal) => signalFinding(signal, 'source_to_active'));
|
|
131
|
+
// Keep every classified location out of the ordinary step findings, including
|
|
132
|
+
// blockers discharged by observed PostgreSQL evidence. Otherwise a verified
|
|
133
|
+
// required field falls through and is emitted again as an add_field blocker.
|
|
134
|
+
const signalLocations = new Set([...blockers, ...classification.warnings].map(({ model, field }) => `${model}:${field ?? ''}`));
|
|
61
135
|
const ordinary = steps
|
|
62
136
|
.map(stepFinding)
|
|
63
137
|
.filter((finding): finding is DeploymentFinding => finding !== null)
|
|
@@ -74,7 +148,49 @@ export function reconcileSourceToActiveResult(active: SchemaJSON | null, source:
|
|
|
74
148
|
}
|
|
75
149
|
: finding;
|
|
76
150
|
});
|
|
77
|
-
|
|
151
|
+
const requiredFieldEvidence: DeploymentFinding[] = requiredFieldsVerified.length === 0 ? [] : [{
|
|
152
|
+
id: 'source_to_active:database_migration_verified',
|
|
153
|
+
code: 'database_migration_verified',
|
|
154
|
+
category: 'data_movement',
|
|
155
|
+
severity: 'info',
|
|
156
|
+
direction: 'source_to_active',
|
|
157
|
+
phase: 'verify',
|
|
158
|
+
owner: database?.ownership === 'ablo' ? 'ablo' : 'application_migration',
|
|
159
|
+
from: { requiredChanges: requiredFieldsVerified.length },
|
|
160
|
+
to: 'satisfied',
|
|
161
|
+
message: `PostgreSQL already enforces ${requiredFieldsVerified.length} required field migration(s) absent from the active artifact.`,
|
|
162
|
+
action: 'No synthetic backfill declaration is needed; preserve the matching non-null columns and activate the reviewed schema.',
|
|
163
|
+
}];
|
|
164
|
+
const typeCorrectionEvidence: DeploymentFinding[] = typeCorrectionsVerified.length === 0 ? [] : [{
|
|
165
|
+
id: 'source_to_active:database_type_correction_verified',
|
|
166
|
+
code: 'database_type_correction_verified',
|
|
167
|
+
category: 'destructive_contract',
|
|
168
|
+
severity: 'info',
|
|
169
|
+
direction: 'source_to_active',
|
|
170
|
+
phase: 'verify',
|
|
171
|
+
owner: database?.ownership === 'ablo' ? 'ablo' : 'application_migration',
|
|
172
|
+
from: { typeCorrections: typeCorrectionsVerified.length },
|
|
173
|
+
to: 'source_database_alignment',
|
|
174
|
+
message: `PostgreSQL already matches ${typeCorrectionsVerified.length} candidate field type correction(s) that differ from the active artifact.`,
|
|
175
|
+
action: 'No physical cast is needed; activate the reviewed schema and use forward recovery because the prior artifact no longer matches PostgreSQL.',
|
|
176
|
+
}];
|
|
177
|
+
const removedModelEvidence: DeploymentFinding[] = removedApplicationModelsPreserved.length === 0 ? [] : [{
|
|
178
|
+
id: 'source_to_active:application_model_removal_verified',
|
|
179
|
+
code: 'application_model_removal_verified',
|
|
180
|
+
category: 'compatibility',
|
|
181
|
+
severity: 'warning',
|
|
182
|
+
direction: 'source_to_active',
|
|
183
|
+
phase: 'verify',
|
|
184
|
+
owner: 'application',
|
|
185
|
+
from: { removedModels: removedApplicationModelsPreserved.length },
|
|
186
|
+
to: 'physical_tables_preserved',
|
|
187
|
+
message: `${removedApplicationModelsPreserved.length} model removal(s) only change the served schema; their application-owned PostgreSQL tables remain present.`,
|
|
188
|
+
action: 'Verify no supported client still reads these models, then activate; Ablo will not drop the preserved application tables.',
|
|
189
|
+
}];
|
|
190
|
+
return {
|
|
191
|
+
operations: steps,
|
|
192
|
+
findings: [...signals, ...requiredFieldEvidence, ...typeCorrectionEvidence, ...removedModelEvidence, ...ordinary],
|
|
193
|
+
};
|
|
78
194
|
}
|
|
79
195
|
|
|
80
196
|
export function reconcilePolicyIntent(source: SchemaJSON): readonly DeploymentFinding[] {
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
type FunctionalUpdateOptions,
|
|
57
57
|
} from '../../client/resources/functionalUpdate.js';
|
|
58
58
|
import type { HeldClaim, HeldLease } from '../../types/streams.js';
|
|
59
|
+
import type { JsonValue } from '../../types/streams.js';
|
|
59
60
|
import { AbloConnectionError, AbloValidationError } from '../../errors.js';
|
|
60
61
|
import type { ReadDependency } from '../../coordination/schema.js';
|
|
61
62
|
import {
|
|
@@ -65,8 +66,22 @@ import {
|
|
|
65
66
|
prepareReadSet,
|
|
66
67
|
type ReadSetContext,
|
|
67
68
|
} from '../../commit/readSetContext.js';
|
|
68
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
recordHttpCommitReceipt,
|
|
71
|
+
recordWebSocketCommitReceipt,
|
|
72
|
+
} from '../../commit/recordRuntime.js';
|
|
69
73
|
import type { EffectiveAuthority } from '../../auth/capability.js';
|
|
74
|
+
import {
|
|
75
|
+
createWebSocketSession,
|
|
76
|
+
} from '../websocket/session.js';
|
|
77
|
+
import type {
|
|
78
|
+
AbloWebSocketSession,
|
|
79
|
+
WebSocketObservedDelta,
|
|
80
|
+
} from '../websocket/sessionContract.js';
|
|
81
|
+
import { subscribeWebSocketReadChanges } from '../websocket/contextSubscription.js';
|
|
82
|
+
import type {
|
|
83
|
+
CoreSyncEventMap,
|
|
84
|
+
} from '../websocket/transport.js';
|
|
70
85
|
|
|
71
86
|
export interface AbloHttpClientOptions<S extends SchemaRecord>
|
|
72
87
|
extends HttpClientConfig<S> {
|
|
@@ -76,6 +91,16 @@ export interface AbloHttpClientOptions<S extends SchemaRecord>
|
|
|
76
91
|
* @default 30_000
|
|
77
92
|
*/
|
|
78
93
|
readonly timeoutMs?: number;
|
|
94
|
+
/** Initial area of interest for `transport: 'websocket'`. */
|
|
95
|
+
readonly syncGroups?: readonly string[];
|
|
96
|
+
/** Application frame names accepted by `subscribe` on the WebSocket transport. */
|
|
97
|
+
readonly collaborationEvents?: readonly string[];
|
|
98
|
+
/** Durable resume position for WebSocket observation. */
|
|
99
|
+
readonly cursorStore?: import('../../client/contract.js').ObserveCursorStore;
|
|
100
|
+
readonly cursorKey?: string;
|
|
101
|
+
readonly reconnectDelay?: number;
|
|
102
|
+
readonly maxReconnectDelay?: number;
|
|
103
|
+
readonly connectTimeoutMs?: number;
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
declare const capturedRowBrand: unique symbol;
|
|
@@ -219,6 +244,30 @@ export type AbloHttpClient<S extends SchemaRecord> = {
|
|
|
219
244
|
readonly sessions: SessionResource<S>;
|
|
220
245
|
};
|
|
221
246
|
|
|
247
|
+
/** The same typed resource client, with server-pushed capabilities carried by WebSocket. */
|
|
248
|
+
export type AbloWebSocketClient<S extends SchemaRecord> = AbloHttpClient<S> & {
|
|
249
|
+
observe(options?: { signal?: AbortSignal }): AsyncIterable<WebSocketObservedDelta>;
|
|
250
|
+
subscribe<K extends keyof CoreSyncEventMap>(
|
|
251
|
+
event: K,
|
|
252
|
+
listener: (...args: CoreSyncEventMap[K]) => void,
|
|
253
|
+
): () => void;
|
|
254
|
+
updateSubscription(
|
|
255
|
+
syncGroups: readonly string[],
|
|
256
|
+
options?: { timeoutMs?: number },
|
|
257
|
+
): Promise<{ syncGroups: string[] }>;
|
|
258
|
+
readonly presence: {
|
|
259
|
+
update(input?: {
|
|
260
|
+
readonly status?: 'online' | 'away' | 'offline';
|
|
261
|
+
readonly customStatus?: string;
|
|
262
|
+
readonly timezone?: string;
|
|
263
|
+
readonly activity?: Readonly<Record<string, JsonValue>>;
|
|
264
|
+
}): Promise<void>;
|
|
265
|
+
};
|
|
266
|
+
readonly collaboration: {
|
|
267
|
+
send(event: string, payload: Readonly<Record<string, JsonValue>>): Promise<void>;
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
|
|
222
271
|
/**
|
|
223
272
|
* Private transport members that pass straight through the facade. Everything
|
|
224
273
|
* else must be a declared schema model; there is no dynamic fallback namespace.
|
|
@@ -497,8 +546,9 @@ function createHttpModelClient<T, C = T>(
|
|
|
497
546
|
/** @internal Constructed only through the public `Ablo({ transport: 'http' })` factory. */
|
|
498
547
|
export function createAbloHttpClient<S extends SchemaRecord>(
|
|
499
548
|
options: AbloHttpClientOptions<S>,
|
|
500
|
-
): AbloHttpClient<S> {
|
|
549
|
+
): AbloHttpClient<S> | AbloWebSocketClient<S> {
|
|
501
550
|
const { schema, onCommitReceipt, ...rest } = options;
|
|
551
|
+
const usesWebSocket = options.transport === 'websocket';
|
|
502
552
|
const readSetContext = createReadSetContext();
|
|
503
553
|
const transport: HttpTransport = createHttpTransport({
|
|
504
554
|
...rest,
|
|
@@ -509,6 +559,21 @@ export function createAbloHttpClient<S extends SchemaRecord>(
|
|
|
509
559
|
recordHttpCommitReceipt(readSetContext, observation);
|
|
510
560
|
onCommitReceipt?.(observation);
|
|
511
561
|
},
|
|
562
|
+
...(usesWebSocket ? {
|
|
563
|
+
dispatchCommit: async (input) => {
|
|
564
|
+
const receipt = await (await webSocketSession()).commit(input);
|
|
565
|
+
recordWebSocketCommitReceipt(readSetContext, {
|
|
566
|
+
receipt,
|
|
567
|
+
operations: input.operations,
|
|
568
|
+
reads: input.reads,
|
|
569
|
+
});
|
|
570
|
+
return receipt;
|
|
571
|
+
},
|
|
572
|
+
dispatchClaim: async (input) => (await webSocketSession()).claim(input),
|
|
573
|
+
releaseDispatchedClaim: (input) => {
|
|
574
|
+
void webSocketSession().then((session) => session.release(input));
|
|
575
|
+
},
|
|
576
|
+
} : {}),
|
|
512
577
|
});
|
|
513
578
|
const schemaModels = new Set(Object.keys(schema.models));
|
|
514
579
|
const clientIdentity = {};
|
|
@@ -517,6 +582,56 @@ export function createAbloHttpClient<S extends SchemaRecord>(
|
|
|
517
582
|
string,
|
|
518
583
|
HttpModelClient<Record<string, unknown>, Record<string, unknown>>
|
|
519
584
|
>();
|
|
585
|
+
let webSocket: AbloWebSocketSession | null = null;
|
|
586
|
+
let webSocketPromise: Promise<AbloWebSocketSession> | null = null;
|
|
587
|
+
const webSocketOpen = new AbortController();
|
|
588
|
+
let disposed = false;
|
|
589
|
+
|
|
590
|
+
const webSocketSession = (): Promise<AbloWebSocketSession> => {
|
|
591
|
+
if (disposed) {
|
|
592
|
+
return Promise.reject(new AbloConnectionError('This Ablo client is disposed.'));
|
|
593
|
+
}
|
|
594
|
+
if (webSocket) return Promise.resolve(webSocket);
|
|
595
|
+
if (webSocketPromise) return webSocketPromise;
|
|
596
|
+
webSocketPromise = (async () => {
|
|
597
|
+
await transport.ready();
|
|
598
|
+
const session = await createWebSocketSession({
|
|
599
|
+
baseUrl: rest.baseURL ?? undefined,
|
|
600
|
+
getAuthToken: async () => (await transport.getAuthToken()) ?? undefined,
|
|
601
|
+
syncGroups: options.syncGroups,
|
|
602
|
+
collaborationEvents: options.collaborationEvents,
|
|
603
|
+
cursorStore: options.cursorStore,
|
|
604
|
+
cursorKey: options.cursorKey,
|
|
605
|
+
reconnectDelay: options.reconnectDelay,
|
|
606
|
+
maxReconnectDelay: options.maxReconnectDelay,
|
|
607
|
+
connectTimeoutMs: options.connectTimeoutMs,
|
|
608
|
+
}, webSocketOpen.signal);
|
|
609
|
+
if (disposed) {
|
|
610
|
+
await session.close();
|
|
611
|
+
throw new AbloConnectionError('This Ablo client is disposed.');
|
|
612
|
+
}
|
|
613
|
+
webSocket = session;
|
|
614
|
+
return session;
|
|
615
|
+
})().finally(() => {
|
|
616
|
+
webSocketPromise = null;
|
|
617
|
+
});
|
|
618
|
+
return webSocketPromise;
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const ready = async (): Promise<void> => {
|
|
622
|
+
await transport.ready();
|
|
623
|
+
if (usesWebSocket) await webSocketSession();
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const dispose = async (): Promise<void> => {
|
|
627
|
+
if (disposed) return;
|
|
628
|
+
disposed = true;
|
|
629
|
+
webSocketOpen.abort();
|
|
630
|
+
const opening = webSocketPromise;
|
|
631
|
+
if (webSocket) await webSocket.close();
|
|
632
|
+
else await opening?.then((session) => session.close()).catch(() => undefined);
|
|
633
|
+
await transport.dispose();
|
|
634
|
+
};
|
|
520
635
|
|
|
521
636
|
const model = (
|
|
522
637
|
name: string,
|
|
@@ -563,11 +678,50 @@ export function createAbloHttpClient<S extends SchemaRecord>(
|
|
|
563
678
|
return {
|
|
564
679
|
context: readSetContext,
|
|
565
680
|
client: clientIdentity,
|
|
566
|
-
onChange: transport.onChange
|
|
681
|
+
onChange: (reads: readonly ReadDependency[], listener: Parameters<typeof transport.onChange>[1]) =>
|
|
682
|
+
usesWebSocket
|
|
683
|
+
? subscribeWhenConnected(webSocketSession, reads, listener)
|
|
684
|
+
: transport.onChange(reads, listener),
|
|
567
685
|
};
|
|
568
686
|
}
|
|
569
687
|
if (typeof prop !== 'string') return undefined;
|
|
570
688
|
if (prop === 'commits') return commits;
|
|
689
|
+
if (prop === 'ready') return ready;
|
|
690
|
+
if (prop === 'dispose') return dispose;
|
|
691
|
+
if (usesWebSocket && prop === 'observe') {
|
|
692
|
+
return async function* (observeOptions?: { signal?: AbortSignal }) {
|
|
693
|
+
yield* (await webSocketSession()).observe(observeOptions);
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
if (usesWebSocket && prop === 'subscribe') {
|
|
697
|
+
return <K extends keyof CoreSyncEventMap>(
|
|
698
|
+
event: K,
|
|
699
|
+
listener: (...args: CoreSyncEventMap[K]) => void,
|
|
700
|
+
) => {
|
|
701
|
+
let stop: (() => void) | undefined;
|
|
702
|
+
let cancelled = false;
|
|
703
|
+
void webSocketSession().then((session) => {
|
|
704
|
+
if (!cancelled) stop = session.subscribe(event, listener);
|
|
705
|
+
});
|
|
706
|
+
return () => { cancelled = true; stop?.(); };
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
if (usesWebSocket && prop === 'updateSubscription') {
|
|
710
|
+
return async (groups: readonly string[], subscriptionOptions?: { timeoutMs?: number }) =>
|
|
711
|
+
(await webSocketSession()).updateSubscription(groups, subscriptionOptions);
|
|
712
|
+
}
|
|
713
|
+
if (usesWebSocket && prop === 'presence') {
|
|
714
|
+
return {
|
|
715
|
+
update: async (input?: Parameters<AbloWebSocketSession['presence']['update']>[0]) =>
|
|
716
|
+
(await webSocketSession()).presence.update(input),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (usesWebSocket && prop === 'collaboration') {
|
|
720
|
+
return {
|
|
721
|
+
send: async (event: string, payload: Readonly<Record<string, JsonValue>>) =>
|
|
722
|
+
(await webSocketSession()).collaboration.send(event, payload),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
571
725
|
// Real protocol members pass through unchanged.
|
|
572
726
|
if (isProtocolMember(prop)) {
|
|
573
727
|
return transport[prop];
|
|
@@ -586,5 +740,18 @@ export function createAbloHttpClient<S extends SchemaRecord>(
|
|
|
586
740
|
// A single boundary cast. `AbloHttpClient<S>` declares only what the model
|
|
587
741
|
// accessor and the passed-through protocol members actually implement, so no
|
|
588
742
|
// method on this type is missing at runtime.
|
|
589
|
-
return facade as AbloHttpClient<S>;
|
|
743
|
+
return facade as AbloHttpClient<S> | AbloWebSocketClient<S>;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function subscribeWhenConnected(
|
|
747
|
+
session: () => Promise<AbloWebSocketSession>,
|
|
748
|
+
reads: readonly ReadDependency[],
|
|
749
|
+
listener: Parameters<typeof subscribeWebSocketReadChanges>[2],
|
|
750
|
+
): () => void {
|
|
751
|
+
let stop: (() => void) | undefined;
|
|
752
|
+
let cancelled = false;
|
|
753
|
+
void session().then((connected) => {
|
|
754
|
+
if (!cancelled) stop = subscribeWebSocketReadChanges(connected, reads, listener);
|
|
755
|
+
});
|
|
756
|
+
return () => { cancelled = true; stop?.(); };
|
|
590
757
|
}
|
|
@@ -124,6 +124,12 @@ import {
|
|
|
124
124
|
} from '../../client/resources/modelOperations.js';
|
|
125
125
|
import type { Duration } from '../../utils/duration.js';
|
|
126
126
|
import { claimDescription, partName } from '../../coordination/schema.js';
|
|
127
|
+
import type {
|
|
128
|
+
ClaimAcquired,
|
|
129
|
+
ClaimBeginPayload,
|
|
130
|
+
ClaimGranted,
|
|
131
|
+
ClaimQueued,
|
|
132
|
+
} from '../../coordination/schema.js';
|
|
127
133
|
import type { BatchFence } from '../../claims/locator.js';
|
|
128
134
|
import {
|
|
129
135
|
subTarget,
|
|
@@ -169,6 +175,7 @@ import {
|
|
|
169
175
|
} from '../../commit/httpRequest.js';
|
|
170
176
|
import { resolveDurableWrites } from '../../commit/durableWrites.js';
|
|
171
177
|
import { createHttpReadOnChange, type HttpReadOnChange } from './subscription.js';
|
|
178
|
+
import type { CommitFrameOperation } from '../websocket/commitFrames.js';
|
|
172
179
|
|
|
173
180
|
/** @internal Private options for the schema-agnostic HTTP protocol transport. */
|
|
174
181
|
export type HttpTransportOptions = Omit<HttpClientConfig, 'schema'> & {
|
|
@@ -191,6 +198,24 @@ export type HttpTransportOptions = Omit<HttpClientConfig, 'schema'> & {
|
|
|
191
198
|
* @default 30_000
|
|
192
199
|
*/
|
|
193
200
|
readonly timeoutMs?: number;
|
|
201
|
+
/** @internal Routes writes through the selected duplex transport. */
|
|
202
|
+
readonly dispatchCommit?: ((input: {
|
|
203
|
+
readonly clientTxId: string;
|
|
204
|
+
readonly operations: readonly CommitFrameOperation[];
|
|
205
|
+
readonly reads?: readonly import('../../coordination/schema.js').ReadDependency[] | null;
|
|
206
|
+
}) => Promise<CommitReceiptWire>) | undefined;
|
|
207
|
+
/** @internal Routes held-claim acquisition through the shared socket. */
|
|
208
|
+
readonly dispatchClaim?: ((input: ClaimBeginPayload & {
|
|
209
|
+
readonly timeoutMs?: number;
|
|
210
|
+
readonly signal?: AbortSignal;
|
|
211
|
+
readonly onQueued?: (event: ClaimQueued) => Error | undefined;
|
|
212
|
+
}) => Promise<ClaimAcquired | ClaimGranted>) | undefined;
|
|
213
|
+
/** @internal Releases a socket-acquired claim on that same session. */
|
|
214
|
+
readonly releaseDispatchedClaim?: ((input: {
|
|
215
|
+
readonly claimId: string;
|
|
216
|
+
readonly entityType: string;
|
|
217
|
+
readonly entityId: string;
|
|
218
|
+
}) => void) | undefined;
|
|
194
219
|
};
|
|
195
220
|
|
|
196
221
|
/** @internal Default per-request deadline for the private HTTP transport. */
|
|
@@ -981,11 +1006,22 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
981
1006
|
path: string;
|
|
982
1007
|
body: unknown;
|
|
983
1008
|
wait: CommitWait;
|
|
1009
|
+
operations: readonly CommitFrameOperation[];
|
|
1010
|
+
reads?: readonly import('../../coordination/schema.js').ReadDependency[] | null;
|
|
984
1011
|
},
|
|
985
1012
|
beforeConfirmation?: (response: CommitResponse) => Promise<void>
|
|
986
1013
|
): Promise<CommitResponse> {
|
|
987
1014
|
return runInHttpCommitLane(async () => {
|
|
988
1015
|
await prepare();
|
|
1016
|
+
if (options.dispatchCommit) {
|
|
1017
|
+
const response = await options.dispatchCommit({
|
|
1018
|
+
clientTxId: input.idempotencyKey,
|
|
1019
|
+
operations: input.operations,
|
|
1020
|
+
...(input.reads !== undefined ? { reads: input.reads } : {}),
|
|
1021
|
+
});
|
|
1022
|
+
if (response.status === 'confirmed') await beforeConfirmation?.(response);
|
|
1023
|
+
return response;
|
|
1024
|
+
}
|
|
989
1025
|
// Startup preparation covers replay. Re-draining here makes every later write wait
|
|
990
1026
|
// behind an ambiguous predecessor from this same process.
|
|
991
1027
|
const replayed = await replayHttpCommitOutbox();
|
|
@@ -1296,6 +1332,17 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1296
1332
|
reads: commitOptions.reads,
|
|
1297
1333
|
};
|
|
1298
1334
|
const wait = commitOptions.wait ?? 'confirmed';
|
|
1335
|
+
const frameOperations: CommitFrameOperation[] = operations.map((operation) => ({
|
|
1336
|
+
type: operation.action.toUpperCase(),
|
|
1337
|
+
model: operation.model,
|
|
1338
|
+
id: operation.id ?? null,
|
|
1339
|
+
input: operation.data ?? null,
|
|
1340
|
+
where: operation.where ?? null,
|
|
1341
|
+
transactionId: operation.transactionId ?? null,
|
|
1342
|
+
claimId: operation.claimId ?? null,
|
|
1343
|
+
readAt: operation.readAt ?? null,
|
|
1344
|
+
fenceToken: operation.fenceToken ?? null,
|
|
1345
|
+
}));
|
|
1299
1346
|
let body: CommitResponse;
|
|
1300
1347
|
try {
|
|
1301
1348
|
body = await dispatchHttpCommit({
|
|
@@ -1304,6 +1351,8 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1304
1351
|
idempotencyKey: clientTxId,
|
|
1305
1352
|
body: requestBody,
|
|
1306
1353
|
wait,
|
|
1354
|
+
operations: frameOperations,
|
|
1355
|
+
reads: commitOptions.reads,
|
|
1307
1356
|
});
|
|
1308
1357
|
} catch (error) {
|
|
1309
1358
|
// Coordination collision over HTTP — surface it to observability on the
|
|
@@ -1529,6 +1578,17 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1529
1578
|
if (action === 'create') requestBody.id = id;
|
|
1530
1579
|
if (data !== undefined) requestBody.data = data;
|
|
1531
1580
|
|
|
1581
|
+
const frameOperation: CommitFrameOperation = {
|
|
1582
|
+
type: action.toUpperCase(),
|
|
1583
|
+
model: modelName,
|
|
1584
|
+
id: action === 'create' ? (id || null) : id,
|
|
1585
|
+
input: data ?? null,
|
|
1586
|
+
transactionId: null,
|
|
1587
|
+
claimId: normalizeClaimId(options?.claimRef) ?? claimHandle?.id ?? null,
|
|
1588
|
+
readAt: readAt ?? null,
|
|
1589
|
+
fenceToken: options?.fenceToken ?? claimHandle?.fenceToken ?? null,
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1532
1592
|
let body: CommitResponse;
|
|
1533
1593
|
try {
|
|
1534
1594
|
body = await dispatchHttpCommit(
|
|
@@ -1541,6 +1601,8 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1541
1601
|
// confirmation. Early queued receipts remain available through the
|
|
1542
1602
|
// lower-level `commits.create` lifecycle surface.
|
|
1543
1603
|
wait: 'confirmed',
|
|
1604
|
+
operations: [frameOperation],
|
|
1605
|
+
reads: options?.reads,
|
|
1544
1606
|
},
|
|
1545
1607
|
beforeConfirmation
|
|
1546
1608
|
);
|
|
@@ -1579,6 +1641,59 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1579
1641
|
params: ClaimParams<Fields>
|
|
1580
1642
|
): Promise<{ id: string; fenceToken?: number }> => {
|
|
1581
1643
|
const contention = resolveClaimContentionOptions(params);
|
|
1644
|
+
if (options.dispatchClaim) {
|
|
1645
|
+
const claimId = createClientTxId();
|
|
1646
|
+
let waited = false;
|
|
1647
|
+
try {
|
|
1648
|
+
const grant = await options.dispatchClaim({
|
|
1649
|
+
claimId,
|
|
1650
|
+
entityType: name,
|
|
1651
|
+
entityId: params.id,
|
|
1652
|
+
...subTarget(params, name),
|
|
1653
|
+
description: claimDescription(params),
|
|
1654
|
+
...(params.ttl !== undefined ? { estimatedMs: toMs(params.ttl) } : {}),
|
|
1655
|
+
queue: contention.wait,
|
|
1656
|
+
...(contention.timeoutMs !== undefined ? { timeoutMs: contention.timeoutMs } : {}),
|
|
1657
|
+
...(contention.signal !== undefined ? { signal: contention.signal } : {}),
|
|
1658
|
+
onQueued: (event) => {
|
|
1659
|
+
waited = true;
|
|
1660
|
+
emitClaimStatus(contention.onStatus, {
|
|
1661
|
+
type: 'queued',
|
|
1662
|
+
claimId: event.claimId,
|
|
1663
|
+
position: event.position,
|
|
1664
|
+
ahead: event.position + 1,
|
|
1665
|
+
});
|
|
1666
|
+
if (
|
|
1667
|
+
contention.maxDepth !== undefined
|
|
1668
|
+
&& event.position >= contention.maxDepth
|
|
1669
|
+
) {
|
|
1670
|
+
return new AbloClaimedError(
|
|
1671
|
+
`Claim queue for ${name}/${params.id} is ${event.position} deep (max ${contention.maxDepth}).`,
|
|
1672
|
+
{ code: 'queue_too_deep' },
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
return undefined;
|
|
1676
|
+
},
|
|
1677
|
+
});
|
|
1678
|
+
emitClaimStatus(contention.onStatus, {
|
|
1679
|
+
type: 'granted',
|
|
1680
|
+
claimId: grant.claimId,
|
|
1681
|
+
waited,
|
|
1682
|
+
});
|
|
1683
|
+
return grant.fenceToken !== undefined
|
|
1684
|
+
? { id: grant.claimId, fenceToken: grant.fenceToken }
|
|
1685
|
+
: { id: grant.claimId };
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
const normalized = error instanceof AbloError
|
|
1688
|
+
? error
|
|
1689
|
+
: new AbloConnectionError(String(error));
|
|
1690
|
+
emitClaimStatus(
|
|
1691
|
+
contention.onStatus,
|
|
1692
|
+
claimAttemptFailure(contention.wait, normalized),
|
|
1693
|
+
);
|
|
1694
|
+
throw error;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1582
1697
|
// The row is named by the URL, so `target` carries only the narrowing a
|
|
1583
1698
|
// claim adds below it. Sending it is what makes a field-scoped claim
|
|
1584
1699
|
// actually field-scoped: the server's conflict rule reads `path`,
|
|
@@ -1656,10 +1771,17 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1656
1771
|
};
|
|
1657
1772
|
const releaseClaim = (
|
|
1658
1773
|
params: ClaimLookupParams<T> | ClaimParams<Fields> | Claim<T>,
|
|
1659
|
-
|
|
1660
|
-
|
|
1774
|
+
claimId?: string,
|
|
1775
|
+
): Promise<void> => {
|
|
1776
|
+
const entityId = isClaimHandle(params) ? params.target.id : params.id;
|
|
1777
|
+
if (claimId && options.releaseDispatchedClaim) {
|
|
1778
|
+
options.releaseDispatchedClaim({ claimId, entityType: name, entityId });
|
|
1779
|
+
return Promise.resolve();
|
|
1780
|
+
}
|
|
1781
|
+
return requestRaw(claimPath(entityId), {
|
|
1661
1782
|
method: 'DELETE',
|
|
1662
1783
|
}).then(() => undefined);
|
|
1784
|
+
};
|
|
1663
1785
|
|
|
1664
1786
|
// One beat on the held lease. A lapsed lease answers `claim_lost`
|
|
1665
1787
|
// (409), which the wire error mapping surfaces as AbloClaimedError —
|
|
@@ -1763,7 +1885,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1763
1885
|
|
|
1764
1886
|
const release = () => {
|
|
1765
1887
|
stopHeartbeatLoop?.();
|
|
1766
|
-
return releaseClaim(params);
|
|
1888
|
+
return releaseClaim(params, claimId);
|
|
1767
1889
|
};
|
|
1768
1890
|
// The handle handed back is a public claim, so its `meta` is the declared
|
|
1769
1891
|
// shape — the same crossing the two decodes above make, spelled the same
|
|
@@ -1904,7 +2026,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1904
2026
|
claim: undefined,
|
|
1905
2027
|
});
|
|
1906
2028
|
} finally {
|
|
1907
|
-
await releaseClaim({ id }).catch(() => {});
|
|
2029
|
+
await releaseClaim({ id }, claimId).catch(() => {});
|
|
1908
2030
|
}
|
|
1909
2031
|
};
|
|
1910
2032
|
|