@kensio/skills 1.13.1

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/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @kensio/skills
2
+
3
+ Installs [Kensio](https://kensio.ai) agent skills into any agent that reads `SKILL.md`.
4
+
5
+ ```bash
6
+ npx @kensio/skills list
7
+ npx @kensio/skills add technical-prose-style
8
+ ```
9
+
10
+ The package carries every published skill inside it, so `add` copies a directory and touches the
11
+ network only once, when npx fetches the package.
12
+
13
+ ## Where it puts them
14
+
15
+ The default is `.agents/skills/` in the current directory. That is the cross-tool convention, read
16
+ by Codex CLI, VS Code, Cursor, Gemini CLI and the other implementations of the
17
+ [Agent Skills specification](https://agentskills.io/specification), so one copy serves whichever of
18
+ them a project uses.
19
+
20
+ ```bash
21
+ npx @kensio/skills add isolated-testing-style --agent claude # .claude/skills/
22
+ npx @kensio/skills add isolated-testing-style --agent copilot # .github/skills/
23
+ npx @kensio/skills add isolated-testing-style --user # under your home directory
24
+ npx @kensio/skills add --all --to ./vendor/skills # anywhere you like
25
+ ```
26
+
27
+ `--force` replaces a skill directory that is already there. Without it, an existing directory is
28
+ left alone and reported.
29
+
30
+ ## What gets installed
31
+
32
+ A skill directory, holding its `SKILL.md` and whatever reference files and scripts it needs. An
33
+ agent loads it from the `description` in the frontmatter when a task matches. There is no command to
34
+ run and no name to invoke.
35
+
36
+ The skills are listed at [kensio.ai/skills](https://kensio.ai/skills), and each one has a page
37
+ covering what it does and when it fires.
38
+
39
+ ## The other ways in
40
+
41
+ Claude Code takes the same skills as plugins, from a marketplace that keeps them updatable in place.
42
+ Each skill is published as its own npm package for pinning in a lockfile. Every
43
+ [release](https://github.com/KensioSoftware/kensio.ai/releases) carries zips for a machine with
44
+ neither. See [kensio.ai/docs](https://kensio.ai/docs).
45
+
46
+ Part of [kensio.ai](https://github.com/KensioSoftware/kensio.ai). Licensed under the Apache License
47
+ 2.0. See the [LICENSE](https://github.com/KensioSoftware/kensio.ai/blob/main/LICENSE) in the
48
+ repository root.
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ // Copies skill directories out of this package and into an agent's skills
3
+ // directory.
4
+ //
5
+ // The whole of installing a skill is putting a directory somewhere an agent
6
+ // looks. Every published skill is bundled here, so this needs no network access
7
+ // after npx has fetched the package, and it works the same for an agent that has
8
+ // never heard of Claude Code.
9
+
10
+ import { cp, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
11
+ import { existsSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
+ const bundledSkills = join(packageRoot, "skills");
18
+
19
+ /**
20
+ * Where each agent looks for skills.
21
+ *
22
+ * `.agents/skills` is the cross-tool convention and the default here: Codex CLI,
23
+ * VS Code and the other implementations of the specification all read it, so one
24
+ * copy serves whatever the reader has installed. The rest are for a project that
25
+ * wants the skill in the directory its own agent already uses.
26
+ */
27
+ const AGENT_DIRECTORIES = {
28
+ agents: { project: ".agents/skills", user: ".agents/skills" },
29
+ claude: { project: ".claude/skills", user: ".claude/skills" },
30
+ codex: { project: ".agents/skills", user: ".agents/skills" },
31
+ copilot: { project: ".github/skills", user: ".copilot/skills" },
32
+ cursor: { project: ".agents/skills", user: ".agents/skills" },
33
+ gemini: { project: ".agents/skills", user: ".agents/skills" },
34
+ };
35
+
36
+ const USAGE = `
37
+ kensio-skills — install Kensio agent skills into any agent
38
+
39
+ npx @kensio/skills list
40
+ npx @kensio/skills add <skill>... [options]
41
+ npx @kensio/skills add --all [options]
42
+
43
+ Options
44
+ --to <dir> install into this directory
45
+ --agent <name> ${Object.keys(AGENT_DIRECTORIES).join(", ")} (default: agents)
46
+ --user install for every project, under your home directory
47
+ --all install every skill
48
+ --force overwrite a skill directory that is already there
49
+ --help this
50
+
51
+ Examples
52
+ npx @kensio/skills add technical-prose-style
53
+ npx @kensio/skills add isolated-testing-style --agent claude --user
54
+ npx @kensio/skills add --all --to ./my-skills
55
+ `;
56
+
57
+ function parseArguments(argv) {
58
+ const options = {
59
+ command: undefined,
60
+ names: [],
61
+ agent: "agents",
62
+ user: false,
63
+ all: false,
64
+ force: false,
65
+ to: undefined,
66
+ help: false,
67
+ };
68
+
69
+ for (let i = 0; i < argv.length; i++) {
70
+ const argument = argv[i];
71
+ switch (argument) {
72
+ case "--help":
73
+ case "-h":
74
+ options.help = true;
75
+ break;
76
+ case "--user":
77
+ options.user = true;
78
+ break;
79
+ case "--all":
80
+ options.all = true;
81
+ break;
82
+ case "--force":
83
+ options.force = true;
84
+ break;
85
+ case "--to":
86
+ options.to = argv[++i];
87
+ break;
88
+ case "--agent":
89
+ options.agent = argv[++i];
90
+ break;
91
+ default:
92
+ if (argument.startsWith("-")) throw new Error(`Unknown option ${argument}`);
93
+ if (options.command === undefined) options.command = argument;
94
+ else options.names.push(argument);
95
+ }
96
+ }
97
+
98
+ return options;
99
+ }
100
+
101
+ async function bundled() {
102
+ const names = (await readdir(bundledSkills, { withFileTypes: true }))
103
+ .filter((entry) => entry.isDirectory())
104
+ .map((entry) => entry.name)
105
+ .sort();
106
+
107
+ return Promise.all(
108
+ names.map(async (name) => ({ name, description: await describe(join(bundledSkills, name)) })),
109
+ );
110
+ }
111
+
112
+ /** The `description` line of a SKILL.md, for the listing. */
113
+ async function describe(dir) {
114
+ const source = await readFile(join(dir, "SKILL.md"), "utf8");
115
+ const match = /^description:[ \t]*(.*)$/m.exec(source.split(/^---$/m)[1] ?? "");
116
+ return match ? match[1].trim() : "";
117
+ }
118
+
119
+ function targetDirectory(options) {
120
+ if (options.to) return resolve(options.to);
121
+
122
+ const agent = AGENT_DIRECTORIES[options.agent];
123
+ if (!agent) {
124
+ throw new Error(
125
+ `Unknown agent "${options.agent}". Known: ${Object.keys(AGENT_DIRECTORIES).join(", ")}`,
126
+ );
127
+ }
128
+
129
+ return options.user ? join(homedir(), agent.user) : resolve(agent.project);
130
+ }
131
+
132
+ async function add(options) {
133
+ const available = await bundled();
134
+ const wanted = options.all ? available.map((skill) => skill.name) : options.names;
135
+
136
+ if (wanted.length === 0) {
137
+ throw new Error("Name at least one skill, or pass --all. `list` shows what there is.");
138
+ }
139
+
140
+ const unknown = wanted.filter((name) => !available.some((skill) => skill.name === name));
141
+ if (unknown.length > 0) {
142
+ throw new Error(`No such skill: ${unknown.join(", ")}. Run \`list\` to see the names.`);
143
+ }
144
+
145
+ const target = targetDirectory(options);
146
+ await mkdir(target, { recursive: true });
147
+
148
+ let installed = 0;
149
+
150
+ for (const name of wanted) {
151
+ const destination = join(target, name);
152
+
153
+ if (existsSync(destination)) {
154
+ if (!options.force) {
155
+ console.log(`↷ ${name} is already in ${target}. Pass --force to replace it.`);
156
+ continue;
157
+ }
158
+ // Replaced rather than merged: a rename upstream would otherwise leave the
159
+ // old reference file behind, and the skill would still link to it.
160
+ await rm(destination, { recursive: true, force: true });
161
+ }
162
+
163
+ await cp(join(bundledSkills, name), destination, { recursive: true });
164
+ console.log(`✔ ${name} → ${join(target, name)}`);
165
+ installed += 1;
166
+ }
167
+
168
+ if (installed > 0) {
169
+ console.log(
170
+ "\nAn agent loads a skill from its description. Ask for the work and it picks it up.",
171
+ );
172
+ }
173
+ }
174
+
175
+ async function main() {
176
+ const options = parseArguments(process.argv.slice(2));
177
+
178
+ if (options.help || options.command === undefined || options.command === "help") {
179
+ console.log(USAGE.trim());
180
+ return;
181
+ }
182
+
183
+ if (!existsSync(bundledSkills) || !(await stat(bundledSkills)).isDirectory()) {
184
+ throw new Error(
185
+ "This package has no skills in it, which means it was packed without its bundle step.",
186
+ );
187
+ }
188
+
189
+ switch (options.command) {
190
+ case "list": {
191
+ for (const skill of await bundled()) {
192
+ const summary =
193
+ skill.description.length > 140
194
+ ? `${skill.description.slice(0, 139)}…`
195
+ : skill.description;
196
+ console.log(`${skill.name}\n ${summary}\n`);
197
+ }
198
+ return;
199
+ }
200
+ case "add":
201
+ await add(options);
202
+ return;
203
+ default:
204
+ throw new Error(`Unknown command "${options.command}". Try \`list\` or \`add\`.`);
205
+ }
206
+ }
207
+
208
+ try {
209
+ await main();
210
+ } catch (error) {
211
+ console.error(`✖ ${error.message}`);
212
+ process.exit(1);
213
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@kensio/skills",
3
+ "version": "1.13.1",
4
+ "description": "Install Kensio agent skills into any agent that reads SKILL.md.",
5
+ "keywords": [
6
+ "agent-skills",
7
+ "claude",
8
+ "cli",
9
+ "codex",
10
+ "copilot",
11
+ "cursor",
12
+ "kensio",
13
+ "skill",
14
+ "skill-md"
15
+ ],
16
+ "homepage": "https://kensio.ai",
17
+ "license": "Apache-2.0",
18
+ "author": "Kensio Software <hugh@kensiosoftware.co.uk>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/KensioSoftware/kensio.ai.git",
22
+ "directory": "packages/skills-cli"
23
+ },
24
+ "bin": {
25
+ "kensio-skills": "bin/kensio-skills.mjs"
26
+ },
27
+ "files": [
28
+ "bin",
29
+ "skills",
30
+ "README.md"
31
+ ],
32
+ "type": "module",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "prepack": "node ../../scripts/bundle-skills.mjs"
38
+ }
39
+ }
@@ -0,0 +1,329 @@
1
+ ---
2
+ name: dynamodb-single-table
3
+ description: Model data in Amazon DynamoDB as one table by default, reading the current AWS guidance before committing to a schema, writing the access patterns down before any keys exist, holding every entity type in one table, overloading generic partition and sort keys across those types, overloading and sparsifying secondary indexes, and splitting items by write rate. Use when designing or reviewing a DynamoDB schema, when a CDK stack is about to gain a second table, when an entity needs a query it has no key for, when application code fetches from two tables to assemble one response, when a Scan or a filter expression appears, when a partition runs hot or a write throttles, and when asked how to model users, orders, events or tenants in DynamoDB.
4
+ license: Apache-2.0
5
+ metadata:
6
+ version: "1.13.1"
7
+ ---
8
+
9
+ # Single-table design in DynamoDB
10
+
11
+ DynamoDB is a key-value store with a query language shaped like an index lookup. Almost all of the
12
+ data modelling advice in the training data is about SQL, and carrying it over produces a table per
13
+ entity, a join in the application layer, and a `Scan` wherever the keys fall short. That is the
14
+ reflex this skill exists to interrupt.
15
+
16
+ The default is **one table per service**, holding every entity type, with keys derived from the
17
+ queries the application makes. AWS puts it plainly in the NoSQL design best practices. "You should
18
+ maintain as few tables as possible in a DynamoDB application." A second table needs a reason from
19
+ [When a second table earns its place](#when-a-second-table-earns-its-place). "One entity, one table"
20
+ is never one of them.
21
+
22
+ ## Read the current guidance before designing a schema
23
+
24
+ This file is a summary, and a summary goes stale. Quotas change, the worked examples carry detail no
25
+ summary keeps, and a schema outlives the session that produced it. **Fetch the sources below when
26
+ designing or reviewing a real schema**, and treat them as the authority wherever they disagree with
27
+ what follows.
28
+
29
+ The two posts, both worth reading end to end for the worked example:
30
+
31
+ - [Creating a single-table design with Amazon DynamoDB](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/)
32
+ - [Single-table vs multi-table design in Amazon DynamoDB](https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/)
33
+
34
+ The developer guide, starting with these four:
35
+
36
+ - [NoSQL design for DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-general-nosql-design.html),
37
+ which is where the "as few tables as possible" rule lives.
38
+ - [Data modeling foundations](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/data-modeling-foundations.html),
39
+ which sets out AWS's own advantages and disadvantages for both foundations.
40
+ - [Partitions and data distribution](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.Partitions.html),
41
+ the mechanism everything else follows from.
42
+ - [Best practices for designing and architecting with DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/best-practices.html),
43
+ an index whose sub-pages cover partition keys, sort keys, secondary indexes, large items, time
44
+ series data, many-to-many relationships and querying.
45
+
46
+ [references/aws-guidance.md](references/aws-guidance.md) collects the mechanics from those sub-pages
47
+ in one place, including throughput per partition, write sharding, index projections, sort key
48
+ patterns and the read cost of a `Scan`. Read it when the question turns on performance or cost. The
49
+ questions about shape are answered here.
50
+
51
+ [NoSQL Workbench](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/workbench.html)
52
+ is the AWS tool for building and visualising a model before writing any code, and it is worth
53
+ suggesting to a user who is modelling something substantial.
54
+
55
+ ## Write the access patterns down first
56
+
57
+ AWS states the ordering as a rule. Do not start designing the schema until the questions it answers
58
+ are known. The schema is a consequence of the queries.
59
+
60
+ Three properties matter before the first attribute is named.
61
+
62
+ - **Data size**, meaning how much is stored and how much comes back in one request.
63
+ - **Data shape**, meaning the shape in the table matches the shape the query wants, with no
64
+ reshaping at read time.
65
+ - **Data velocity**, meaning where the peak load lands, which decides how the keys distribute.
66
+
67
+ Enumerate the patterns and write them where the reviewer of the pull request can see them (the CDK
68
+ stack, an ADR, a comment above the key builders). Each entry records what the caller holds, what
69
+ comes back, the ordering and the expected cardinality. For a small orders service:
70
+
71
+ | Access pattern | Caller holds | Returns | Order |
72
+ | --------------------------------- | ------------ | --------------------- | ------------ |
73
+ | Get a customer profile | customer id | one item | |
74
+ | Get an order with its lines | order id | one order, 1-50 lines | line sku |
75
+ | List a customer's orders | customer id | 0-500 orders | newest first |
76
+ | Find a customer by email | email | one item | |
77
+ | List open orders across customers | nothing | 0-2000 orders | oldest first |
78
+
79
+ A pattern that arrives later usually costs a new secondary index or a backfill. It never costs a new
80
+ table.
81
+
82
+ ## One table, every entity type
83
+
84
+ Items sharing a partition key form an **item collection**, stored together and sorted by sort key.
85
+ That is the whole mechanism behind single-table design. Related items of different types land in one
86
+ collection and one `Query` returns them.
87
+
88
+ The arguments for it, in the order AWS makes them:
89
+
90
+ - **Locality of reference.** Keeping related data together is the first general principle in the
91
+ best practices, ahead of every key-design detail. An order and its lines share a partition, and
92
+ one `Query` returns both. The multi-table version issues a `GetItem` for the order and a `Query`
93
+ for the lines, and pays two round trips to assemble one response.
94
+ - **Reads cost less.** One query for two items totalling under 4 KB is 0.5 read units eventually
95
+ consistent. Two queries for the same two items cost 1 read unit, because each is billed at 0.5.
96
+ Latency follows the same shape, and two calls average worse than one.
97
+ - **Traffic smooths out.** Aggregating several usage patterns onto one table produces a steadier
98
+ overall curve than any single pattern has on its own, in the way an index moves more smoothly than
99
+ a share in it. Provisioned mode reaches a higher utilisation as a result.
100
+ - **Cost tracks the item.** A write is charged per kilobyte of the whole item, so a view counter
101
+ living on the same item as the video metadata charges for the metadata on every increment.
102
+ Splitting by write rate is a modelling decision the table boundary cannot make.
103
+ - **Operational surface stays flat.** One table means one set of alarms, one backup policy, one
104
+ capacity mode, one stream, one set of IAM statements, and one customer managed key to rotate.
105
+ - **It keeps the modelling honest.** A table per entity looks like a relational schema and invites
106
+ relational habits. One table has no shape to fall back on except the access patterns.
107
+
108
+ Name the table after the service (`orders-service-data`), never after an entity. A table called
109
+ `Users` has already lost the argument.
110
+
111
+ Symptoms of the relational reflex, all of which mean the keys are wrong:
112
+
113
+ - Two or more `GetItem` calls in sequence to build one response.
114
+ - A `Scan` with a `FilterExpression` doing the selection.
115
+ - A secondary index added for each new query.
116
+ - An `orderId` attribute on the customer item, used as a foreign key by the application.
117
+
118
+ ## Generic key names and entity prefixes
119
+
120
+ Partition and sort key attributes are named `PK` and `SK`, and secondary index keys `GSI1PK`,
121
+ `GSI1SK` and so on. A key named `customerId` can only ever hold a customer.
122
+
123
+ Every value carries a prefix naming its entity type, and every item carries a `type` attribute for
124
+ the deserialiser, for stream consumers and for exports.
125
+
126
+ ```
127
+ PK SK type Attributes
128
+ CUSTOMER#c-42 #PROFILE customer email, name, createdAt
129
+ ORDER#o-981 #ORDER order customerId, status, total, placedAt
130
+ ORDER#o-981 LINE#sku-0007 orderLine qty, price
131
+ ORDER#o-981 LINE#sku-0031 orderLine qty, price
132
+ ```
133
+
134
+ `Query` on `PK = "ORDER#o-981"` returns the order and every line in one request, in sort key order.
135
+ `#ORDER` comes back first because `#` sorts below the letters. A `#` prefix is the usual trick for
136
+ pinning a parent item to the top of its item collection.
137
+
138
+ Sort keys are byte-ordered strings, and hierarchy in them is free. AWS gives
139
+ `[country]#[region]#[state]#[county]#[city]#[neighborhood]` as the shape, queryable at every level
140
+ with `begins_with` and `between`. A timestamp in ISO 8601 sorts chronologically without parsing. Pad
141
+ numbers so `sku-10` sorts after `sku-9`.
142
+
143
+ Choosing the partition key is also a throughput decision. A key with many distinct values used
144
+ evenly (a customer id) distributes well, and one with few values (a status code) or one that
145
+ concentrates writes on the current period (a date rounded to the day) does not. Every partition
146
+ serves 3,000 read units and 1,000 write units per second.
147
+ [references/aws-guidance.md](references/aws-guidance.md) covers the arithmetic, the AWS table of
148
+ good and bad keys, and write sharding for the cases that need it.
149
+
150
+ ## Overload the secondary indexes
151
+
152
+ Keep the number of indexes small. Every global secondary index is a full copy of the attributes it
153
+ projects, rewritten whenever a projected attribute changes, and it consumes its own write capacity.
154
+ AWS states the rule as "keep the number of indexes to a minimum" and warns that a seldom-used index
155
+ costs storage and I/O without buying performance. The default quota caps a table at 20 of them, and
156
+ a design that adds one index per access pattern will feel the write cost long before it reaches that
157
+ ceiling.
158
+
159
+ Index overloading is what keeps the count down. The same index attributes carry different meanings
160
+ for different entity types:
161
+
162
+ | Item | GSI1PK | GSI1SK | Serves |
163
+ | --------- | ----------------------- | -------------------------- | ------------------------ |
164
+ | customer | `EMAIL#ada@example.com` | `EMAIL` | find a customer by email |
165
+ | order | `CUSTOMER#c-42` | `PLACED#2026-03-01T09:14Z` | a customer's orders |
166
+ | orderLine | (absent) | (absent) | |
167
+
168
+ Two access patterns, one index, and the line items stay out of it because they carry no `GSI1PK`.
169
+ That last part is the **sparse index**. An index keyed on an attribute only some items hold contains
170
+ only those items. Set `GSI2PK` to `STATUS#OPEN` while an order is open and remove the attribute when
171
+ it closes. The index then holds the open orders and nothing else, and the query for them reads no
172
+ closed orders at all.
173
+
174
+ Prefer a GSI to an LSI. An LSI has to exist when the table is created, cannot be deleted afterwards,
175
+ shares the table's throughput, and caps every item collection at 10 GB. A GSI can be added later and
176
+ carries its own capacity. The case for an LSI is a strongly consistent read on an alternate sort
177
+ key, which a GSI cannot give (GSI reads are eventually consistent, always).
178
+
179
+ Decide the projection on every index. `ALL` removes fetches and roughly doubles storage and write
180
+ cost. See [references/aws-guidance.md](references/aws-guidance.md) for the trade.
181
+
182
+ ## Split items by write rate
183
+
184
+ The write cost of an item is its whole size rounded up to the next kilobyte. An attribute that
185
+ changes constantly, sitting on an item that is mostly static, charges for the static part every
186
+ time.
187
+
188
+ ```
189
+ PK SK type Attributes
190
+ VIDEO#v-3 #METADATA video title, description, tags, uploadedAt (2 KB, rarely written)
191
+ VIDEO#v-3 #VIEWS viewCount count (1 WCU per increment)
192
+ ```
193
+
194
+ Same partition, so one `Query` still returns both. The counter now costs one write unit, down from
195
+ two. This split cuts across entity boundaries. A table-per-entity layout has no way to express it.
196
+
197
+ The same move handles items that outgrow 400 KB. Break the item into chunks under one partition key
198
+ and order them by sort key, which AWS calls vertical partitioning.
199
+
200
+ ## Many-to-many is an adjacency list
201
+
202
+ Model both sides in one table. Top-level entities are partition keys, and a relationship is an item
203
+ inside the partition whose sort key is the id of the thing on the other end.
204
+
205
+ ```
206
+ PK SK type
207
+ INVOICE#i-3 #INVOICE invoice
208
+ INVOICE#i-3 BILL#b-7 invoiceBill
209
+ INVOICE#i-3 BILL#b-9 invoiceBill
210
+ BILL#b-7 #BILL bill
211
+ ```
212
+
213
+ A `Query` on `INVOICE#i-3` gives every bill on the invoice. The other direction comes from an
214
+ **inverted index**, a GSI whose partition key is the table's sort key, so a query on `BILL#b-7`
215
+ returns every invoice carrying that bill. One extra index buys the reverse of every relationship in
216
+ the table.
217
+
218
+ Where the traversal is graph-shaped and needs several hops at low latency, Amazon Neptune is the
219
+ tool AWS points to.
220
+
221
+ ## Denormalise what is stable, and write the copies together
222
+
223
+ A join is replaced either by co-location or by duplication. Copy the customer name onto the order
224
+ item when the order view needs it, and keep the copies consistent in one `TransactWriteItems` (up to
225
+ 100 items in one call). Where the fan-out is too wide for a transaction, repair from a DynamoDB
226
+ stream.
227
+
228
+ Duplicate attributes that rarely change. An attribute that changes often turns every update into a
229
+ fan-out write across every copy, and at that point a second `Query` for the current value is
230
+ cheaper.
231
+
232
+ ## Uniqueness is a second item
233
+
234
+ There is no unique index. A uniqueness rule is an item whose key is the unique value, written in the
235
+ same transaction as the entity, both writes conditional on the key being free:
236
+
237
+ ```typescript
238
+ await documents.send(new TransactWriteCommand({
239
+ TransactItems: [
240
+ {
241
+ Put: {
242
+ TableName: table,
243
+ Item: { PK: `CUSTOMER#${id}`, SK: "#PROFILE", type: "customer", email },
244
+ ConditionExpression: "attribute_not_exists(PK)",
245
+ },
246
+ },
247
+ {
248
+ Put: {
249
+ TableName: table,
250
+ Item: { PK: `EMAIL#${email}`, SK: "#EMAIL", type: "emailClaim", customerId: id },
251
+ ConditionExpression: "attribute_not_exists(PK)",
252
+ },
253
+ },
254
+ ],
255
+ }));
256
+ ```
257
+
258
+ Either both land or neither does. The claim item is another entity type in the same table, and it
259
+ needs releasing when the email changes.
260
+
261
+ ## Query, never Scan, and never select with a filter
262
+
263
+ A `FilterExpression` is applied after the read and charged on everything read. Filtering 10,000
264
+ items down to 3 costs 10,000 items of read capacity and returns pages that look half-empty. Filters
265
+ are for trimming a result set the key condition has already narrowed, and never for choosing which
266
+ items to fetch.
267
+
268
+ A `Scan` in a request path is a modelling failure. It reads the whole table, slows as the table
269
+ grows, and takes its capacity from one partition at a time, which throttles the requests that share
270
+ that partition. A single 1 MB page of 4 KB items costs 128 eventually consistent read units in one
271
+ burst.
272
+
273
+ In a batch job over the whole table a `Scan` is legitimate. Set `Limit` to cap the page size and use
274
+ parallel segments once the table passes about 20 GB.
275
+
276
+ Watch the 1 MB page limit on `Query` too. Code that reads `Items` without following
277
+ `LastEvaluatedKey` silently truncates.
278
+
279
+ ## When a second table earns its place
280
+
281
+ Each of these is a real reason, and each produces a second table holding several entity types. None
282
+ of them produces a table per entity.
283
+
284
+ - **Whole-table settings that need to differ.** Backups and point-in-time recovery are per table,
285
+ and so are encryption keys and the table class. Mission-critical data mixed with disposable data
286
+ gets backed up as one unit. A multi-tenant application needing a key per tenant needs a table per
287
+ tenant or client-side encryption. Historical data mixed with operational data loses most of the
288
+ Infrequent Access saving.
289
+ - **Stream pressure.** One stream carries every change to every entity type, and a shard supports
290
+ about two concurrent readers before throttling. Entities needing separate downstream pipelines
291
+ (orders into Step Functions, registrations into EventBridge) can exhaust that. Lambda event
292
+ filters keep the irrelevant records off the bill, and the Kinesis Client Library does not.
293
+ - **Analytics exports.** An immutable event log and a mutable entity set want different export
294
+ strategies. Full exports suit mutable data and streaming suits the log.
295
+ - **High-volume time series data.** AWS names this as an explicit exception, along with datasets
296
+ whose access patterns have nothing in common. A table per time period is the usual shape.
297
+ - **A different owner.** Another service owning the data means a shared table couples two
298
+ deployments and two IAM policies. The table boundary follows the service boundary.
299
+ - **A framework that fights it.** GraphQL resolvers map cleanly onto one entity per table, and
300
+ higher-level SDK mappers struggle when one response holds several classes. AWS lists both as
301
+ disadvantages of single-table design.
302
+
303
+ Absent one of these, the second table is the SQL habit reasserting itself.
304
+
305
+ ## Keep key construction in one place
306
+
307
+ Single-table keys are strings with structure, and structure spread across handlers as template
308
+ literals drifts. One module owns building and parsing them, and nothing else concatenates a `#`.
309
+
310
+ ```typescript
311
+ export const keys = {
312
+ customer: (id: string) => ({ PK: `CUSTOMER#${id}`, SK: "#PROFILE" }),
313
+ order: (orderId: string) => ({ PK: `ORDER#${orderId}`, SK: "#ORDER" }),
314
+ orderLine: (orderId: string, sku: string) => ({ PK: `ORDER#${orderId}`, SK: `LINE#${sku}` }),
315
+ ordersForCustomer: (id: string) => ({ GSI1PK: `CUSTOMER#${id}` }),
316
+ };
317
+ ```
318
+
319
+ A `Query` over one partition returns several entity types, so the item type is a discriminated union
320
+ keyed on the `type` attribute. Parse into that union at the edge of the data access layer and let
321
+ the rest of the code hold real types.
322
+
323
+ ## Testing the model
324
+
325
+ Access patterns are testable, and each one deserves a test that seeds a few items and asserts what
326
+ the query returns. The
327
+ [`yulin-aws-simulation`](https://github.com/KensioSoftware/kensio.ai/tree/main/plugins/yulin-aws-simulation)
328
+ skill covers running those tests against a simulated DynamoDB with the real CDK table definition,
329
+ including key schema and index projections.