morseDOCS

SDK Cookbooks

End-to-end recipes for morse-sdk: publish content, revise entries, encrypt and decrypt, delegate write access, and tear down resources. For installation and adapter wiring, see SDK Setup.

Prerequisites

RequirementDetailsPurpose
Bun or Nodev1.2+ / ≥ 18JavaScript runtime
SUI + WAL tokensSUI faucet · Swap SUI for WALGas fees (SUI) and Walrus storage fees (WAL)
Bech32 private keyA suiprivkey1... stringSDK authentication. Export from your wallet's settings

Make sure the SDK is installed and configured before continuing. See SDK Setup for details.

Lifecycle

Publications hold collections; collections hold entries; entries hold an append-only vector of revisions. Permission management lives in Sui; payloads live in Walrus.

The examples below assume you have built a context as shown in SDK Setup. Browser flows carry a signer (via WalletStandardSigner.fromAccount) instead of keypair. Walrus adapters (walrusWriter, walrusReader) are constructed where needed. See Picking a Walrus Adapter for options.

Creating a publication

Returns the publication's ID along with an OwnerCap for governance and a PublisherCap for write access, both transferred to the caller's address atomically.

typescript
import { ContractAbortError, createPublication } from "@arcadiasystems/morse-sdk";

try {
  const created = await createPublication(adapter, config, {
    name: "My Publication",
    slug: "my-publication",
  });
  // created.publicationId, created.ownerCapId, created.publisherCapId
} catch (err) {
  if (err instanceof ContractAbortError && err.reason === "ESlugAlreadyExists") {
    // slug is globally unique - pick another or look up the existing publication
  }
  throw err;
}

Adding a collection

storageMode is fixed at creation; switching the mode for the same collection name requires deleting and recreating.

typescript
import { createCollection } from "@arcadiasystems/morse-sdk";

await createCollection(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  name: "blog",
  storageMode: "blob",  // or "quilt" for packed small payloads
});

Adding an entry

Uploads bytes to Walrus and attaches them as the first revision of a new entry in 2 transactions. Returns the assigned entryId (a monotonic u64) used to reference the entry in all subsequent operations.

typescript
import {
  addEntryFromBytes,
  DefaultWalrusWriteAdapter,
} from "@arcadiasystems/morse-sdk";

const walrusWriter = DefaultWalrusWriteAdapter.fromConfig(
  { network: "testnet", suiClient: client },
  keypair,  // or `signer` in the browser
);

const entry = await addEntryFromBytes(adapter, config, {
  walrus: walrusWriter,
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  collectionName: "blog",
  name: "first-post",
  bytes: new TextEncoder().encode("hello world"),
  contentType: "text/plain",
  upload: { epochs: 3, deletable: true },
});
// entry.entryId is the stable monotonic ID (0, 1, 2, ...)

addEntryFromBytes uses the Walrus flow-aware capability to combine certify_blob and add_entry_to_collection into a single PTB, reducing the total from 3 transactions to 2. The HTTP publisher path collapses this further to 1 transaction.

Handle UncertifiedBlobError
If transaction 1 (register + upload) succeeds but transaction 2 (certify + add_entry) fails, the SDK throws UncertifiedBlobError carrying blobObjectId and blobId. The blob exists on storage nodes but remains uncertified and is released when the registration expires. Log the IDs or surface the error for debugging.

See also: CLI Cookbooks → Publishing your first entry

Encrypted content

Encrypts content with Seal, uploads ciphertext to Walrus, and attaches it as the first revision of an encrypted entry in 2 transactions. Returns the same entryId as the plaintext path. Encrypted entries are always created as drafts and accessed via Seal decryption, not the public read path.

typescript
import {
  addEncryptedEntryFromBytes,
  buildPublisherSealId,
  createCollection,
  DefaultSealAdapter,
  DefaultWalrusWriteAdapter,
} from "@arcadiasystems/morse-sdk";

// Create a blob-mode collection for encrypted content.
await createCollection(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  name: "secret-blog",
  storageMode: "blob",
});

const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client);
const walrusWriter = DefaultWalrusWriteAdapter.fromConfig(
  { network: "testnet", suiClient: client },
  keypair,
);

// Fresh per-entry identity. publicId(32) || tag(1=Publisher) || nonce.
const nonce = crypto.getRandomValues(new Uint8Array(16));
const sealId = buildPublisherSealId(created.publicationId, nonce);

const plaintext = new TextEncoder().encode("secret content");
const result = await addEncryptedEntryFromBytes(adapter, config, {
  walrus: walrusWriter,
  seal,
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  collectionName: "secret-blog",
  name: "private-doc",
  plaintext,
  contentType: "application/octet-stream",
  sealId,
  upload: { epochs: 3, deletable: true },
});

buildPublisherSealId constructs the Seal identity from the publication ID and a random nonce. Each entry needs a fresh nonce; reusing a nonce produces the same identity. When creating a SessionKey, always pass config.originalPackageId ?? config.packageId as the package ID. Seal uses the original (genesis) package address, not the current upgrade address.

Encrypted entries are always created as drafts
publishFromDraft and publishDirect always write non-encrypted revisions; the contract hardcodes encrypted=false on the public path. For encrypted content, use addEncryptedEntryFromBytes / appendEncryptedDraftRevision and read via seal.decrypt.

Reading encrypted entries

The full read flow for encrypted content combines entry metadata lookup, Walrus blob fetch, and Seal decryption. Decryption requires three things: a Seal SessionKey (built via wallet personal-message signature), the sealId bound to the revision, and a non-revoked PublisherCap ID.

typescript
import { SessionKey } from "@mysten/seal";
import { DefaultSealAdapter, HttpAggregatorReadAdapter } from "@arcadiasystems/morse-sdk";

const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client);
const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client);

// 1. Read entry metadata to get the draft revision.
const fetched = await reader.getEntry(
  created.publicationId,
  "secret-blog",
  result.entryId,
);
const revision = fetched.revisions[fetched.draftHead];

// 2. Extract the blobRef and sealId from the revision.
const blobRef = revision.blobRef;   // { kind: "blob", blobObjectId: "0x..." }
const sealId = revision.sealId;     // Uint8Array

// 3. Fetch ciphertext from Walrus.
const ciphertext = await walrusReader.readBlobRef(blobRef);

// 4. Create a SessionKey and decrypt with Seal.
const sessionKey = await SessionKey.create({
  address: keypair.toSuiAddress(),
  packageId: config.originalPackageId ?? config.packageId,
  ttlMin: 10,
  signer: keypair,  // or `signer` in the browser
  suiClient: client,
});

const plaintext = await seal.decrypt(ciphertext, {
  sessionKey,
  sealId,
  publisherCapId: created.publisherCapId,
});

console.log(new TextDecoder().decode(plaintext));

Encrypted draft revisions

After creating an encrypted entry with addEncryptedEntryFromBytes, use appendEncryptedDraftRevision to add subsequent encrypted revisions to the same entry. You encrypt and upload the blob yourself, then pass the blobObjectId to the SDK.

typescript
import {
  appendEncryptedDraftRevision,
  buildPublisherSealId,
  DefaultSealAdapter,
  DefaultWalrusWriteAdapter,
} from "@arcadiasystems/morse-sdk";

const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client);
const walrusWriter = DefaultWalrusWriteAdapter.fromConfig(
  { network: "testnet", suiClient: client },
  keypair,
);

// 1. Build a fresh Seal identity for the new revision.
const nonce = crypto.getRandomValues(new Uint8Array(16));
const sealId = buildPublisherSealId(created.publicationId, nonce);

// 2. Encrypt the updated content and upload to Walrus.
const updatedPlaintext = new TextEncoder().encode("updated secret content");
const { ciphertext } = await seal.encrypt(updatedPlaintext, { sealId });
const blob = await walrusWriter.uploadBlob(ciphertext, {
  epochs: 3,
  deletable: true,
});

// 3. Append the encrypted revision to the existing entry.
const revision = await appendEncryptedDraftRevision(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  collectionName: "secret-blog",
  entryId: result.entryId,              // from addEncryptedEntryFromBytes
  blobObjectId: blob.blobObjectId,
  contentType: "application/octet-stream",
  sealId,
});

console.log("Revision", revision.revisionId, "appended");

See also: CLI Cookbooks → Encrypted content

Reading it back

Single-object reads, paginated lists, and an async iterator for full-collection scans. The reader is gRPC-backed.

typescript
import { HttpAggregatorReadAdapter } from "@arcadiasystems/morse-sdk";

const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client);

// Single entry by ID.
const fetched = await reader.getEntry(
  created.publicationId,
  "blog",
  entry.entryId,
);
const firstRevision = fetched.revisions[0];

// Fetch the actual content bytes from Walrus.
const content = await walrusReader.readBlobRef(firstRevision.blobRef);
console.log(new TextDecoder().decode(content));

// Paginated list.
const page = await reader.listEntries(
  created.publicationId,
  "blog",
  { limit: 50 },
);
console.log(page.results.length, "entries, next cursor:", page.nextCursor);

// Stream every entry in a collection.
for await (const e of reader.scanEntries(created.publicationId, "blog")) {
  console.log(e.id, e.name);
}

// Single revision by entry ID and revision index.
const revision = await reader.getRevision(
  created.publicationId,
  "blog",
  entry.entryId,
  0,
);
Ordering caveat
listEntries and scanEntries return entries in dynamic-field object-store order, not chronological order. Sort by entry.id if you need insertion order.

Publication and cap lookups

Beyond entries, the reader resolves publications and the caps that control them, useful for dashboards ("which publications does this wallet own?") and for verifying delegated write access:

typescript
// Publication metadata: name, slug, collections.
const publication = await reader.getPublication(created.publicationId);

// Publications owned (via OwnerCap) by an address.
const owned = await reader.listPublicationsOwnedBy(adapter.address);
// owned.results: { ownerCapId, publicationId } handles (OwnedPublication)

// PublisherCaps held by an address, and a single cap by ID.
const caps = await reader.listPublisherCapsOwnedBy(adapter.address);
const cap = await reader.getPublisherCap(created.publisherCapId);
// cap.publicationId, cap.holder

All list methods return one page per call (EntryListPage, PublicationListPage, and PublisherCapListPage), each with a results array and a nextCursor (null when exhausted). They accept { limit, cursor, signal } options (default limit 50; the cursor is opaque: feed nextCursor back in to get the next page). scanEntries auto-paginates and takes { pageSize, signal } instead. Single-object reads (getPublication, getPublisherCap, getEntry, getRevision) throw NotFoundError when the target does not exist.

See also: CLI Cookbooks → Reading it back

Publishing a new revision

Append a new public revision directly, or stage a draft first and promote it. Revisions are append-only; existing revisions are never edited in place. Two heads track progress: draftHead (work in progress) and publicHead (the live version).

Option A: publish direct

Skip the draft stage and append a public revision in one step. Requires a pre-uploaded blob (use uploadBlob first).

typescript
import { publishDirect, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk";

const walrusWriter = DefaultWalrusWriteAdapter.fromConfig(
  { network: "testnet", suiClient: client },
  keypair,
);

const blob = await walrusWriter.uploadBlob(
  new TextEncoder().encode("updated content"),
  { epochs: 3, deletable: true },
);

const result = await publishDirect(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  collectionName: "blog",
  entryId: entry.entryId,
  blobObjectId: blob.blobObjectId,
  contentType: "text/plain",
});
// result.revisionId is the index of the new public revision

Option B: draft then publish

Stage a draft revision (updates draftHead only), iterate on it, then promote to a public revision. Both steps require a pre-uploaded blob (use uploadBlob first). The promotion appends a fresh public revision; the draft blob is referenced for validation but not reused.

typescript
import { appendDraftRevision, publishFromDraft, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk";

const walrusWriter = DefaultWalrusWriteAdapter.fromConfig(
  { network: "testnet", suiClient: client },
  keypair,
);

// 1. Upload the draft content
const draftBlob = await walrusWriter.uploadBlob(
  new TextEncoder().encode("draft content"),
  { epochs: 3, deletable: true },
);

// 2. Stage a draft revision
const draft = await appendDraftRevision(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  collectionName: "blog",
  entryId: entry.entryId,
  blobObjectId: draftBlob.blobObjectId,
  contentType: "text/plain",
});

// 3. Upload the final content and promote the draft
const finalBlob = await walrusWriter.uploadBlob(
  new TextEncoder().encode("final content"),
  { epochs: 3, deletable: true },
);

const published = await publishFromDraft(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  collectionName: "blog",
  entryId: entry.entryId,
  draftRevisionId: draft.revisionId,
  blobObjectId: finalBlob.blobObjectId,
  contentType: "text/plain",
});

See also: CLI Cookbooks → Publishing a new revision

Delegating write access

Morse separates ownership (OwnerCap) from publishing (PublisherCap). The owner issues a cap bound to a co-author's address, granting them write access. Once issued, the co-author can publish entries using their own key. Revoking the cap causes further writes to abort on-chain. See Access Control → Issuing publisher caps for details.

typescript
import {
  issuePublisherCap,
  revokePublisherCap,
  destroyPublisherCap,
} from "@arcadiasystems/morse-sdk";

// Owner issues a cap bound to a co-author's address
const issued = await issuePublisherCap(adapter, config, {
  publicationId: created.publicationId,
  ownerCapId: created.ownerCapId,
  holder: "0xCO_AUTHOR_ADDRESS",
});
// issued.publisherCapId

// Owner revokes; further writes with this cap abort with EPublisherCapRevoked
await revokePublisherCap(adapter, config, {
  publicationId: created.publicationId,
  ownerCapId: created.ownerCapId,
  publisherCapId: issued.publisherCapId,
});

// Co-author destroys their own cap (requires an adapter built from the co-author's keypair; see SDK Setup)
await destroyPublisherCap(coAuthorAdapter, config, {
  publicationId: created.publicationId,
  publisherCapId: issued.publisherCapId,
});
Holder binding is immutable
A PublisherCap is bound to the holder address at issue time. Transferring the Sui object to a different address does NOT grant write rights. To rotate authority to a new address, destroy the old cap and issue a fresh one.

See also: CLI Cookbooks → Delegating write access

Transferring ownership

Transfer the OwnerCap to a new address. The recipient gains governance rights: issuing/revoking publisher caps and deleting the publication. Existing publisher caps remain usable until individually revoked.

typescript
import { transferOwnership } from "@arcadiasystems/morse-sdk";

await transferOwnership(adapter, config, {
  ownerCapId: created.ownerCapId,
  recipient: "0xNEW_OWNER_ADDRESS",
});
One-way for the sender
Once transferred, the original owner loses all governance rights immediately. The new owner could transfer back, but there is no built-in undo.

See also: CLI Cookbooks → Transferring ownership

Deleting and tearing down

Deletion works bottom-up: remove entries first, then the collections, then the publication. The Move layer enforces this: deleting a non-empty collection or publication aborts on-chain. This assumes the caller still holds both a PublisherCap and the OwnerCap.
Entry IDs are permanently retired on deletion and never reused. See Entries → Deleting an entry for details.

typescript
import {
  deleteEntry,
  deleteCollection,
  deletePublication,
} from "@arcadiasystems/morse-sdk";

// 1. Remove all entries from each collection
await deleteEntry(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  collectionName: "blog",
  entryId: entry.entryId,
});
await deleteEntry(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  collectionName: "secret-blog",
  entryId: result.entryId,
});

// 2. Remove the empty collections
await deleteCollection(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  name: "blog",
});
await deleteCollection(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  name: "secret-blog",
});

// 3. Delete the publication (requires OwnerCap; reader verifies it's empty)
await deletePublication(reader, adapter, config, {
  publicationId: created.publicationId,
  ownerCapId: created.ownerCapId,
});

See also: CLI Cookbooks → Deleting and tearing down

Entry path options

The SDK provides two ways to publish content. addEntryFromBytes (and addEncryptedEntryFromBytes for encrypted content) handles upload and on-chain registration in one call and is the recommended default. For cases that need more control, use the split form: uploadBlob followed by addEntry.

Use case Function Transactions
Publish raw bytes as a new entry (typical case) addEntryFromBytes 2
Publish encrypted bytes as a new entry addEncryptedEntryFromBytes 2
Reuse one blob across many entries (deduplication) uploadBlob once, then addEntry N times 2 + N
Decouple upload and add-entry (draft-then-attach UX) uploadBlob at upload time, addEntry at publish time 2 + 1
Server pre-uploads; browser only adds entries uploadBlob (server), addEntry (browser) 2 + 1

Split upload path

When you need control over the upload step (for example, to retry blob registration independently or reuse a blob across entries), split the operation into two calls:

ts
import { addEntry, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk";

const walrusWriter = DefaultWalrusWriteAdapter.fromConfig(
  { network: "testnet", suiClient: client },
  keypair,
);

const content = new TextEncoder().encode("hello world");

// 1. Upload blob to Walrus (2 wallet popups: register + certify).
const blob = await walrusWriter.uploadBlob(content, {
  epochs: 3,
  deletable: true,
});

// 2. Reference the blob in a new entry (1 wallet popup).
const entry = await addEntry(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  collectionName: "blog",
  name: "first-post",
  blobObjectId: blob.blobObjectId,
  contentType: "text/plain",
});

addEntryFromBytes requires an adapter that implements WalrusFlowCapable, which combines the certify and add-entry steps into a single transaction. DefaultWalrusWriteAdapter supports this; the HTTP publisher adapter does not. Passing an incompatible adapter throws a TransportError before any I/O. Use the split path (uploadBlob + addEntry) instead. To branch at runtime, use the isWalrusFlowCapable(walrusWriter) type guard, which detects the capability on any WalrusWriteAdapter.

Next steps