Azure Table Storage Patterns

SkillFiles & storage

Esposter Azure Table Storage patterns — the AZURE_MAX_PAGE_SIZE / AZURE_MAX_BATCH_SIZE constants, partition and row key design, reverse-ticked timestamps and the ascending mirror table, batching writes that share a partitionKey instead of one round trip per row, reading through getEntityWithEtag and writing conditionally, serializeClauses filters with entity-typed Clause arrays and CompositeKeyPropertyNames, the shared getPartitionKeyFilter, counting only after a capped read and bounding the walk, optional-init entity constructors, and soft-delete, plus deep dives on the submitTransactionBatches write path and conflict replay, the updateEntityConditionally retry loop, and observing or intercepting table writes in tests. Apply when reading or writing Azure Table Storage data (messages, moderation logs) in server code.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Azure Table Storage Patterns skill

What this skill tells your AI

The instructions your AI receives, as published by esposter/esposter in .agents/skills/azure-table/SKILL.md and read by ahel’s review.

Deep dives

  • references/batch-writes.md — when writing many entities that share a partitionKey, or when a batched write's rows can individually conflict.
  • references/conditional-writes.md — when a write's body is computed from an entity the same request just read (a votes map, a files array, any "Replace").
  • references/testing.md — when a test must observe, intercept or time a table write, or cross a page boundary.

Key Constants (from @esposter/db-schema)

ConstantValueWhen to use
AZURE_MAX_PAGE_SIZE1000byPage({ maxPageSize: AZURE_MAX_PAGE_SIZE }) for listing entities
AZURE_MAX_BATCH_SIZE100Chunk size for submitTransaction — Azure hard limit per call

Always import from @esposter/db-schema, never redefine locally.

Partition / Row Key Design

  • partitionKey = the owning room idAzureTable.Messages, AzureTable.MessagesAscending, AzureTable.ModerationLog all partition by roomId. Entity factories take roomId and assign it to partitionKey (createMessageEntity); a transaction can only span one partition, so this is also what makes room-scoped batch writes legal.
  • rowKey = getReverseTickedTimestamp() — Azure Table sorts rows within a partition by rowKey ascending only, so a reverse-ticked key makes a plain scan return newest-first with no sort.
  • AzureTable.MessagesAscending mirrors each message with the tick un-reversed as its rowKey (same partitionKey) to get oldest-first ordering — see createMessage in @esposter/db.

Reverse-Ticked Timestamps

getReverseTickedTimestamp(timestamp = now()) (@esposter/db-schema) returns AZURE_SELF_DESTRUCT_TIMER - timestamp as a string, where now() (@esposter/shared) is epoch nanoseconds and AZURE_SELF_DESTRUCT_TIMER is "9".repeat(30).

  • It is its own inversegetReverseTickedTimestamp(rowKey) maps a stored rowKey back to the real timestamp, and vice versa. That's how cursors and the ascending-table mirror are built; never hand-roll the subtraction.
  • Never generate a rowKey with Date.now() or an ISO string — millisecond resolution collides under load, and lexical ISO sorts oldest-first.
  • Nanosecond resolution is what makes the bare timestamp a sufficient key, so don't "harden" it with a random suffix or a retry loop. now() reads process.hrtime, which is monotonic and advances between two consecutive calls in the same process, so two writes to one partition cannot land on one key — and the key staying exactly the timestamp is what lets cursors and the ascending mirror decode it back.
  • A test that fakes timers breaks that guarantee, and the failure looks like a production bug — Vitest's default toFake set includes process.hrtime, so every row written to one partition gets an identical rowKey. Narrow it to toFake: ["Date"] (testing skill, references/timers-and-hand-resolved-promises.md).

Batch Writes

Never spend a round trip per entity when the entities share a partitionKey — chunk them into submitTransaction instead. This is the Azure-side twin of the drizzle skill's batch-insert rule: a loop of createEntity/updateEntity awaits is one network latency per row, so an unremarkable 1000-row write becomes 1000 sequential calls on a request a user is waiting on. Partition-per-owner designs (partitionKey = roomId, = programId) mean the writes usually already qualify — check whether they do before reaching for Promise.all, which still issues a request per row.

Paginate at AZURE_MAX_PAGE_SIZE, chunk transactions at AZURE_MAX_BATCH_SIZE, and let submitTransactionBatches (@esposter/db) own the chunking — never hand-roll the slice loop. A write needing per-batch conflict handling is the one case submitTransactionBatches can't serve; it chunks with chunk (@esposter/shared), still never an index-stepping for with .slice().

Read-Modify-Write Is Conditional

A server-side read-modify-write over an entity reads through getEntityWithEtag and writes conditionally. Azure Table stores an entity as one blob, so a write that echoes back a field the caller computed from what it read carries the whole version it read. Two of them running at once both compute from the same version, and the later write silently erases the earlier change — no error, no log, and the caller whose write landed first is told it succeeded. This applies to any procedure whose write depends on what it just read.

getEntity is the wrong reader here — it exists to drop the etag for callers that don't need it. getEntityWithEtag returns { entity, etag }, and updateEntity forwards its extra arguments to the SDK, so the conditional write is updateEntity(client, entity, "Merge", { etag }). Where a shared procedure performs the read (as getMessageProcedure does), the etag belongs on the procedure context beside the entity — the round trip is already paid, and every procedure built on it then gets the option.

A rejected conditional write is a 412, meaning only that the version is stale — the caller's intent is still valid, so re-read and re-apply rather than surfacing it. updateEntityConditionally owns that loop — do not hand-roll it (references/conditional-writes.md).

Classify a rejection with the checkIs* helpers in @esposter/db, and take RestError from @azure/core-rest-pipeline — never from @azure/storage-blob or @azure/data-tables, which both re-export that one class. The helpers classify errors from both SDKs, so an instanceof against a re-export depends on the two resolving one shared copy, and the day a version bump splits them the check silently stops recognising the other SDK's errors.

Filter Clauses

Build OData filter strings with serializeClauses from @esposter/azure.

  • Clause<T extends Record<string, unknown>> has no default — type the array with the entity being queried (const clauses: Clause<FooEntity>[] = [...]), never a bare Clause[].
  • Always CompositeKeyPropertyNames for partitionKey/rowKey — never an entity's own PropertyNames, never a string literal.
  • Entity-specific fields stay on their own PropertyNames constantFooEntityPropertyNames.bar, with ItemMetadataPropertyNames.deletedAt for metadata.
  • Null clause helpers infer automaticallygetTableNullClause(ItemMetadataPropertyNames.deletedAt), never getTableNullClause<FooEntity>(...). getCursorWhereAzureTable returns Clause<TItem>[], typed via a cast in its body since deserialized cursor keys are plain strings at runtime.
const filter = serializeClauses([
  { key: CompositeKeyPropertyNames.partitionKey, operator: BinaryOperator.eq, value: roomId },
  { key: StandardMessageEntityPropertyNames.userId, operator: BinaryOperator.eq, value: userId },
  getTableNullClause(ItemMetadataPropertyNames.deletedAt),
] as Clause<StandardMessageEntity>[]);

"Everything under this partition" is getPartitionKeyFilter(id) (@esposter/azure), never a hand-built one-clause serializeClauses call and never a template literal. Every table partitions on its owning entity's id, so a read, a count and a purge of the same entity all start from that one filter — writing it once is what keeps the three from disagreeing after a key-shape change. A feature that also filters on its own columns drops back to the clause array above; a feature that only re-labels the partition filter for its domain (getSurveyResponseFilter) is a one-line named wrapper over it, not a second implementation.

Counting — Only After a Capped Read, and Bounded

Azure Table has no count API — countEntities (from @esposter/db) walks every matching page with a keys-only projection. Two rules keep the walk cheap and honest:

  • Only count when a capped read filled. A read under its cap answers for itself (rows.length < cap ? rows.length : await countFooEntities(...)); only a full page has something to be missing.
  • Bound the walk when the count feeds a display. Pass countEntities's maxCount argument (callers name their own bound). A count that hit the bound is a floor, not a total — every surface must render it as one ("N+", via the shared truncation formatter), never as an exact number.

Entity Class Constructors

deserializeEntity calls new cls() with no arguments, so every Azure entity constructor must declare init optional (init?:) and access via optional chaining:

export class MyEntity extends AzureEntity {
  declare myField: string;

  constructor(init?: Partial<MyEntity> & ToData<CompositeKeyEntity>) {
    super();
    Object.assign(this, init);
    this.myField = init?.myField ?? "default"; // use ?. not just .
  }
}

Soft-Delete

Set deletedAt and updatedAt together via serializeEntity. getTableNullClause(ItemMetadataPropertyNames.deletedAt) filters to non-deleted rows only.

const now = new Date();
serializeEntity({ deletedAt: now, partitionKey, rowKey, updatedAt: now });

Signals

GitHub stars
23
Forks
3
Last commit
Sep 2026

ahel review

  • S4info
    community integration — published by esposter, not azure

Automated review, not a security audit. Ruleset v1.

Advanced
Catalog kind
skill
Gateway key
azure-table
Source
github.com/esposter/esposter