Azure Table Storage Patterns
SkillFiles & storageEsposter 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.
No other account needed.
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 apartitionKey, 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, afilesarray, 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)
| Constant | Value | When to use |
|---|---|---|
AZURE_MAX_PAGE_SIZE | 1000 | byPage({ maxPageSize: AZURE_MAX_PAGE_SIZE }) for listing entities |
AZURE_MAX_BATCH_SIZE | 100 | Chunk 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 id —AzureTable.Messages,AzureTable.MessagesAscending,AzureTable.ModerationLogall partition byroomId. Entity factories takeroomIdand assign it topartitionKey(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 byrowKeyascending only, so a reverse-ticked key makes a plain scan return newest-first with no sort.AzureTable.MessagesAscendingmirrors each message with the tick un-reversed as itsrowKey(samepartitionKey) to get oldest-first ordering — seecreateMessagein@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 inverse —
getReverseTickedTimestamp(rowKey)maps a storedrowKeyback 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
rowKeywithDate.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()readsprocess.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
toFakeset includesprocess.hrtime, so every row written to one partition gets an identicalrowKey. Narrow it totoFake: ["Date"](testingskill,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 bareClause[].- Always
CompositeKeyPropertyNamesforpartitionKey/rowKey— never an entity's ownPropertyNames, never a string literal. - Entity-specific fields stay on their own
PropertyNamesconstant —FooEntityPropertyNames.bar, withItemMetadataPropertyNames.deletedAtfor metadata. - Null clause helpers infer automatically —
getTableNullClause(ItemMetadataPropertyNames.deletedAt), nevergetTableNullClause<FooEntity>(...).getCursorWhereAzureTablereturnsClause<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'smaxCountargument (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