DATASTORE PROVIDER
A datastore provider is a TypeScript module that implements data persistence for
a custom backend (cloud object storage, networked filesystem, etc.). The file
lives in extensions/datastores/ and exports a datastore object that Swamp
loads at runtime.
Every Swamp command needs a swamp-club.com account
(Swamp Account Requirement).
Setting up a datastore that uses a custom provider additionally requires the
credential to carry the datastore:* scope when it is a collective token. The
built-in filesystem type needs no scope.
For datastore configuration and usage (setup, sync, locking, CLI commands), see the Datastore Configuration reference.
Export Shape
import { z } from "npm:zod@4";
export const datastore = {
type: "@mycollective/my-datastore",
name: "My Datastore",
description: "Store data in my backend",
configSchema: z.object({
bucket: z.string().describe("Storage bucket name"),
region: z.string().optional().describe("Region"),
}),
createProvider(config: Record<string, unknown>): DatastoreProvider {
// validate config, return provider instance
},
};Fields
| Field | Type | Description |
|---|---|---|
type |
string |
Unique identifier in @collective/name format. Reserved collectives: swamp, si. |
name |
string |
Human-readable display name. |
description |
string |
Short description shown in swamp datastore output. |
configSchema |
z.ZodTypeAny |
Zod schema that validates the config object in .swamp.yaml. |
createProvider |
(config: Record<string, unknown>) => DatastoreProvider |
Factory that returns a provider instance. |
DatastoreProvider Interface
The object returned by createProvider must implement the following methods.
| Method | Signature | Required | Description |
|---|---|---|---|
createLock |
(datastorePath: string, options?: LockOptions) => DistributedLock |
Yes | Create a distributed lock for the given path. |
createVerifier |
() => DatastoreVerifier |
Yes | Create a health-check verifier. |
resolveDatastorePath |
(repoDir: string) => string |
Yes | Resolve the datastore path for a given repo. |
createSyncService |
(repoDir: string, cachePath: string) => DatastoreSyncService |
No | Create a sync service for bidirectional transfer. |
resolveCachePath |
(repoDir: string) => string | undefined |
No | Resolve the local cache path. Return undefined if no cache is used. |
datastoreControlPlaneStore |
() => DatastoreControlPlaneStore |
No | Return a read-only store for the datastore-wide control plane. See DatastoreControlPlaneStore. |
Swamp reads the datastore format marker (datastore-format) through
datastoreControlPlaneStore() before every command that opens the datastore, so
the method must be cheap:
- Building the store creates no sync service, binds no namespace and sends no
request. The first request is sent by
get. - A
getis one request, with no separate credentials preflight.
Without the method, Swamp reads the marker through a fresh sync service's
controlPlaneStore().
Supporting Interfaces
DistributedLock
Coordinates exclusive access to prevent concurrent writers from corrupting data.
| Method | Signature | Description |
|---|---|---|
acquire |
() => Promise<void> |
Acquire the lock. Retries until timeout. |
release |
() => Promise<void> |
Release the lock. |
withLock |
<T>(fn: () => Promise<T>) => Promise<T> |
Acquire, run fn, release. Releases on error. |
inspect |
() => Promise<LockInfo | null> |
Return current lock metadata, or null if unheld. |
forceRelease |
(expectedNonce: string) => Promise<boolean> |
Force-release a stale lock matching the nonce. |
LockOptions
| Field | Type | Default | Description |
|---|---|---|---|
lockKey |
string |
— | Custom lock key (overrides default). |
ttlMs |
number |
30000 |
Lock lifetime before considered stale. |
retryIntervalMs |
number |
1000 |
Retry interval when lock is held. |
maxWaitMs |
number |
60000 |
Maximum wait before giving up. |
DatastoreVerifier
| Method | Signature | Description |
|---|---|---|
verify |
() => Promise<DatastoreHealthResult> |
Run a health check. |
DatastoreHealthResult
| Field | Type | Description |
|---|---|---|
healthy |
boolean |
Whether the backend is reachable. |
message |
string |
Human-readable status message. |
latencyMs |
number |
Round-trip latency in milliseconds. |
datastoreType |
string |
The provider type identifier. |
details |
Record<string, string> (optional) |
Extra diagnostic data. |
DatastoreSyncService
Implement this if the backend supports bidirectional file synchronization.
| Method | Signature | Required | Description |
|---|---|---|---|
pullChanged |
(options?: DatastoreSyncOptions) => Promise<number | void> |
Yes | Download changed files from the remote. |
pushChanged |
(options?: DatastoreSyncOptions) => Promise<number | void> |
Yes | Upload changed files to the remote. |
markDirty |
(options?: DatastoreSyncOptions) => Promise<void> |
Yes | Signal that the local cache has uncommitted work. |
clearDirty |
(relPaths: readonly string[]) => Promise<void> |
No | Drop the per-path marks whose keys equal relPaths, made before the call. Called after Swamp undoes a refused push's writes. |
capabilities |
() => SyncCapabilities |
No | Advertise what this sync service supports. |
hydrateFile |
(relPath: string, options?: DatastoreSyncOptions) => Promise<boolean> |
No | Download a single file on demand. Returns true on success. |
fetchContent |
(relPath: string, options?: DatastoreSyncOptions) => Promise<Uint8Array | null> |
No | Read one remote file without touching the local cache. |
controlPlaneStore |
() => ControlPlaneStore |
No | Read and write small control-plane records. Required when capabilities().controlPlane is true. See ControlPlaneStore. |
pullChanged and pushChanged return the number of files transferred, or void.
markDirty is called before every cache write. Implementations must be
idempotent and cheap. When options.relPath is present, it identifies the
cache-relative path about to be written. When absent, a bulk mutation occurred
and the next pushChanged must perform a full walk. See the markDirty
contract in the Swamp source for the complete set of rules.
clearDirty is called after Swamp undoes writes it had marked: when a push is
refused because its model lock was lost, Swamp discards the refused versions and
restores the datastore's copy of each file. Without the call, the marks those
writes left make the next push delete files under a marked directory that the
cache lacks, and upload a stale latest marker. It follows these rules:
- A mark is dropped only when its key equals one of
relPaths. A mark on a parent or child path is a different key and stays. - Only marks made before the call are dropped. A
markDirtythat starts during or after the call keeps its mark. - A bulk mark and an overflowed path set stay.
- Nothing remote is read or written, and no pull watermark changes.
relPathsare keys asmarkDirtyreceived them: cache-relative, forward-slash-normalized, with the namespace prefix when there is one.- A path with no mark is ignored.
Swamp core treats the presence of clearDirty as the capability, as with
fetchContent. Without it, a refused push keeps its marks and warns once,
naming the data names whose marks stay. The
clear-dirty conformance case holds
an implementation to these rules.
capabilities returns a SyncCapabilities object advertising optional features
the sync service supports. Omit this method when no optional features are
implemented.
hydrateFile downloads a single content file from the remote backend to the
local cache. The relPath argument is forward-slash-normalized and
cache-relative. Implementations must write atomically (write to a temporary
path, then rename). Required only when the extension advertises
lazyHydration: true in its capabilities.
fetchContent resolves to the bytes of one file as the remote holds it, or
null when the remote has no such file. It follows these rules:
- Only a missing file is
null. Any other failure rejects, so an unreachable remote is never read as a deleted file. Honoroptions.signalas the other methods do. - Nothing is written locally: no cache file is created, replaced or removed, and
no dirty state or pull watermark changes. A local file at
relPaththat differs from the remote one is neither returned nor modified. relPathis cache-relative and forward-slash-normalized, as forhydrateFile. With a namespace it already starts with{namespace}/, so the implementation must not add the namespace again. Whenoptions.namespaceis unset or empty,relPathis read from the datastore root.- A
relPaththat is absolute or has a..segment is rejected, never resolved. - The returned bytes are not kept in instance state. In
swamp servethe sync service lives as long as the process. The whole file is returned in memory, so the method suits small files such as run records.
Swamp core treats the presence of fetchContent as the capability. There is no
SyncCapabilities flag for it.
DatastoreSyncOptions
Options passed to pullChanged, pushChanged, markDirty, hydrateFile, and
fetchContent.
| Field | Type | Description |
|---|---|---|
signal |
AbortSignal |
Optional. Abort signal for bounding sync duration. |
relPath |
string |
Optional. Cache-relative, forward-slash-normalized path of the file being written. Set only on markDirty calls. |
context |
SyncContext |
Optional. Domain-level sync context. Passed when the extension advertises scopedSync in its capabilities. |
namespace |
string |
Optional. Namespace whose subtree the operation targets, under the {namespace}/ prefix in the remote. Unset or empty means the whole datastore. |
metadataOnly |
boolean |
Optional. When true, pullChanged downloads only metadata files and skips content files under data/. |
SyncContext
Describes what a sync operation is about.
| Field | Type | Description |
|---|---|---|
models |
ReadonlyArray<{ modelType: string; modelId: string }> |
Behaviors relevant to the current operation. |
SyncCapabilities
Capabilities a sync service advertises to Swamp core.
| Field | Type | Description |
|---|---|---|
scopedSync |
boolean |
Optional. When true, pullChanged and pushChanged receive context.models for scoped transfers. |
lazyHydration |
boolean |
Optional. When true, the extension supports metadataOnly pulls and the hydrateFile method. |
controlPlane |
boolean |
Optional. When true, the sync service implements controlPlaneStore. |
ControlPlaneStore
Returned by a sync service's controlPlaneStore(). It reads and writes small
records in the datastore directly, bypassing the cache and sync. Keys are
slash-delimited paths such as heartbeats/<id>, kept under _control/ in the
backend, below the namespace when one is bound.
| Method | Signature | Required | Description |
|---|---|---|---|
put |
(key: string, data: Uint8Array) => Promise<void> |
Yes | Write the record, replacing any that exists. |
putIfAbsent |
(key: string, data: Uint8Array) => Promise<boolean> |
No | Create the record only if the key holds none, atomically. Exactly one concurrent create wins. |
get |
(key: string, options?: ControlPlaneReadOptions) => Promise<Uint8Array | null> |
Yes | The record, or null when the key holds none. |
delete |
(key: string) => Promise<void> |
Yes | Remove the record. Removing a key that holds none is not an error. |
list |
(prefix: string) => Promise<string[]> |
Yes | The keys under prefix, each in full, as passed to put. |
A store without putIfAbsent cannot hold the records of workflow signal waits.
Swamp refuses to start a workflow that contains a wait_for_signal step on such
a datastore.
A get that ignores options still works, because Swamp stops waiting at its
own deadline either way. Honouring options.signal is recommended.
ControlPlaneReadOptions
Options passed to get on a ControlPlaneStore or a
DatastoreControlPlaneStore.
| Field | Type | Description |
|---|---|---|
signal |
AbortSignal |
Optional. Cancels the read. When it aborts, stop the request in flight and any retry backoff, and reject. Retrying until then is fine. |
DatastoreControlPlaneStore
Returned by the provider's datastoreControlPlaneStore(). It reads records at
the datastore-wide _control/<key>, whatever namespace a repository uses. It
binds no namespace and shares no state with any sync service the provider
builds, so Swamp can use it before a command's own sync service exists.
| Method | Signature | Description |
|---|---|---|
get |
(key: string, options?: ControlPlaneReadOptions) => Promise<Uint8Array | null> |
The record at _control/<key>, or null. |
get follows these rules:
- Only a missing record returns
null. Access denied and every other failure reject, so Swamp can tell an unreadable marker from a missing one. options.signalmust be honoured: when it aborts, the request and any retry backoff stop andgetrejects.
Check an implementation with
assertDatastoreControlPlaneStoreConformance.
Conformance Testing
The @swamp-club/swamp-testing package provides conformance assertions for
datastore providers and sync services.
assertSyncServiceConformance
import { assertSyncServiceConformance } from "@swamp-club/swamp-testing";Asserts that a DatastoreSyncService implementation satisfies the behavioral
contract. Tests that pullChanged, pushChanged, and markDirty exist and are
callable. When capabilities() is present, validates it returns a well-formed
SyncCapabilities object.
Deno.test("s3 sync service contract", async () => {
const syncService = provider.createSyncService!("/repo", "/cache");
await assertSyncServiceConformance(syncService);
});With capability assertions:
Deno.test("s3 sync service with scoped sync", async () => {
const syncService = provider.createSyncService!("/repo", "/cache");
await assertSyncServiceConformance(syncService, { expectScopedSync: true });
});SyncServiceConformanceOptions
| Field | Type | Description |
|---|---|---|
expectScopedSync |
boolean |
Optional. When true, asserts capabilities().scopedSync === true. |
assertSyncServiceRoundTripConformance
Runs two sync services on one shared backend, as two machines would see it, and asserts that files move between their caches the way Swamp core relies on. It returns the cases that passed and the cases it skipped, each with a reason.
Three cases cover fetchContent. Each is skipped when the second service has no
fetchContent.
| Case | Asserts | Also skipped when |
|---|---|---|
fetch-content |
Returns the remote's bytes, and null for a missing file. Rejects a .. or absolute path. Writes nothing into the cache, and leaves a differing local file and its pending push alone. |
|
fetch-content-error |
A fetchContent that cannot read the remote rejects instead of resolving to null. |
The fixture has no failNextFetch. |
fetch-content-namespace |
A cache-relative path that starts with the namespace is read without the namespace being added again. | The fixture names no namespace. |
The clear-dirty case covers clearDirty: a directory mark that clearDirty
dropped makes the next push delete and send nothing, and a mark made after the
call is still pushed. It is skipped when the first service has no clearDirty.
assertDatastoreControlPlaneStoreConformance
import { assertDatastoreControlPlaneStoreConformance } from "@swamp-club/swamp-testing";Asserts that a provider's datastoreControlPlaneStore() meets the contract the
datastore format check relies on. It checks that get:
- reads a record that a never-pulled sync service's
controlPlaneStore()wrote at the datastore-wide root; - returns
nullfor a missing key; - does not see a record written only under a namespace;
- rejects when its signal is already aborted.
It also checks that using the store binds no namespace: a sync service built before it can still pull under a namespace afterwards and read that namespace's records. The suite writes only under a key prefix of its own and removes what it wrote.
Deno.test("datastore-wide control-plane store conformance", async () => {
await assertDatastoreControlPlaneStoreConformance({
openDatastoreStore: () => provider.datastoreControlPlaneStore!(),
// a fresh, never-pulled service with its own cache each call
openSyncService: async () =>
provider.createSyncService!("/repo", await Deno.makeTempDir()),
});
});DatastoreControlPlaneStoreConformanceOptions
| Field | Type | Description |
|---|---|---|
openDatastoreStore |
() => DatastoreControlPlaneStore | Promise<DatastoreControlPlaneStore> |
Required. The provider's datastoreControlPlaneStore(). |
openSyncService |
() => DatastoreSyncService | Promise<DatastoreSyncService> |
Required. A fresh sync service from the same provider, never pulled or pushed, with a cache of its own on each call. It must implement controlPlaneStore. |
namespace |
string |
Optional. The namespace a service is bound to by pulling with it. Defaults to a fresh one per run. A backend that only pulls registered namespaces needs one registered first. |
Packaging
Declare the datastore in your extension's manifest:
datastores:
- my_datastore.tsThe file path is relative to the extensions/datastores/ directory within the
extension root.