1import type {
2 AttributeValue,
3 ScalarAttributeType,
4} from "@distilled.cloud/aws/dynamodb";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as S from "effect/Schema";
8import * as AST from "effect/SchemaAST";
9import {
10 getSetValueAST,
11 isClassSchema,
12 isListSchema,
13 isMapSchema,
14 isNumberSchema,
15 isRecordLikeSchema,
16 isRecordSchema,
17 isSetSchema,
18 isStringSchema,
19 isStructSchema,
20} from "../../Schema.ts";
21
22// this seems important for handling S.Struct.Fields https://effect.website/docs/schema/classes/#recursive-types-with-different-encoded-and-type
23// interface CategoryEncoded extends Schema.Struct.Encoded<typeof fields> { .. }
24
25/**
26 * Raised when a JavaScript value cannot be marshalled to a DynamoDB
27 * `AttributeValue` (e.g. a function or symbol).
28 */
29export class InvalidAttributeValue extends Data.TaggedError(
30 "InvalidAttributeValue",
31)<{
32 message: string;
33 value: any;
34}> {}
35
36/**
37 * Marshal a plain JavaScript value into a DynamoDB `AttributeValue`.
38 *
39 * @example Marshal a nested object
40 * ```typescript
41 * const item = yield* toAttributeValue({ name: "Alice", scores: [1, 2, 3] });
42 * // { M: { name: { S: "Alice" }, scores: { L: [{ N: "1" }, ...] } } }
43 * ```
44 */
45export const toAttributeValue: (
46 value: any,
47) => Effect.Effect<AttributeValue, InvalidAttributeValue, never> = Effect.fn(
48 function* (value: any) {
49 if (value === undefined) {
50 return {
51 NULL: false,
52 };
53 } else if (value === null) {
54 return {
55 NULL: true,
56 };
57 } else if (typeof value === "boolean") {
58 return {
59 BOOL: value,
60 };
61 } else if (typeof value === "string") {
62 return {
63 S: value,
64 };
65 } else if (typeof value === "number") {
66 return {
67 N: value.toString(10),
68 };
69 } else if (Array.isArray(value)) {
70 return {
71 L: yield* Effect.all(value.map(toAttributeValue)),
72 };
73 } else if (value instanceof Set) {
74 const setType = getType(value);
75 if (setType === "EMPTY_SET") {
76 return {
77 SS: [],
78 };
79 } else if (Array.isArray(setType)) {
80 return {
81 L: yield* Effect.all(setType.map(toAttributeValue)),
82 };
83 } else if (setType === "SS") {
84 return {
85 SS: Array.from(value.values()),
86 };
87 } else if (setType === "NS") {
88 return {
89 NS: Array.from(value.values()).map((value) => value.toString(10)),
90 };
91 } else if (setType === "BS") {
92 return {
93 BS: Array.from(value.values()),
94 };
95 } else {
96 return {
97 L: yield* Effect.all(
98 Array.from(value.values()).map(toAttributeValue),
99 ),
100 };
101 }
102 } else if (Buffer.isBuffer(value)) {
103 return {
104 B: new Uint8Array(value),
105 };
106 } else if (value instanceof File) {
107 return {
108 B: new Uint8Array(yield* Effect.promise(() => value.arrayBuffer())),
109 };
110 } else if (value instanceof Uint8Array) {
111 return {
112 B: value,
113 };
114 } else if (value instanceof ArrayBuffer) {
115 return {
116 B: new Uint8Array(value),
117 };
118 } else if (typeof value === "object") {
119 return {
120 M: Object.fromEntries(
121 yield* Effect.all(
122 Object.entries(value).map(([key, value]) =>
123 toAttributeValue(value).pipe(Effect.map((value) => [key, value])),
124 ),
125 ),
126 ),
127 };
128 }
129
130 return yield* Effect.fail(
131 new InvalidAttributeValue({
132 message: `Unknown value type: ${typeof value}`,
133 value,
134 }),
135 );
136 },
137);
138
139/**
140 * Unmarshal a DynamoDB `AttributeValue` back into a plain JavaScript value
141 * (inverse of {@link toAttributeValue}).
142 */
143export const fromAttributeValue = (value: AttributeValue): any => {
144 if (value.NULL) {
145 return null;
146 } else if (typeof value.BOOL === "boolean") {
147 return value.BOOL;
148 } else if (value.L) {
149 return value.L.map(fromAttributeValue);
150 } else if (value.M) {
151 return Object.fromEntries(
152 Object.entries(value.M).map(([key, value]) => [
153 key,
154 fromAttributeValue(value!),
155 ]),
156 );
157 } else if (value.N) {
158 return parseFloat(value.N);
159 } else if (value.S) {
160 // how do we know if this is a date?
161 return value.S;
162 } else if (value.SS) {
163 return new Set(value.SS);
164 } else if (value.NS) {
165 return new Set(value.NS);
166 } else if (value.BS) {
167 return new Set(value.BS);
168 } else {
169 throw new Error(`Unknown attribute value: ${JSON.stringify(value)}`);
170 }
171};
172
173type ValueType =
174 | "L"
175 | "BOOL"
176 | "EMPTY_SET"
177 | "M"
178 | "NULL"
179 | "N"
180 | "M"
181 | "S"
182 | "SS"
183 | "BS"
184 | "NS"
185 | "undefined";
186
187const getType = (value: any): ValueType | ValueType[] => {
188 if (value === undefined) {
189 return "undefined";
190 } else if (value === null) {
191 return "NULL";
192 } else if (typeof value === "boolean") {
193 return "BOOL";
194 } else if (typeof value === "string") {
195 return "S";
196 } else if (typeof value === "number") {
197 return "N";
198 } else if (Array.isArray(value)) {
199 return "L";
200 } else if (value instanceof Set) {
201 return value.size === 0
202 ? "EMPTY_SET"
203 : (() => {
204 const types = Array.from(value.values())
205 .flatMap(getType)
206 .filter((type, i, arr) => arr.indexOf(type) === i);
207
208 return types.length === 1
209 ? types[0] === "S"
210 ? "SS"
211 : types[0] === "N"
212 ? "NS"
213 : types[0] === "BOOL"
214 ? "BS"
215 : types[0]
216 : "L";
217 })();
218 } else if (value instanceof Map) {
219 return "M";
220 } else if (typeof value === "object") {
221 return "M";
222 } else {
223 throw new Error(`Unknown value type: ${typeof value}`);
224 }
225};
226
227export const isScalarAttributeType = (
228 type: string,
229): type is ScalarAttributeType => {
230 return type === "S" || type === "N" || type === "B";
231};
232
233export const toAttributeType = (schema: S.Schema<any>) => {
234 if (isStringSchema(schema)) {
235 return "S";
236 } else if (isNumberSchema(schema)) {
237 return "N";
238 } else if (isRecordLikeSchema(schema)) {
239 return "M";
240 } else if (isStringSetSchema(schema)) {
241 return "SS";
242 } else if (isNumberSetSchema(schema)) {
243 return "NS";
244 } else if (isListSchema(schema)) {
245 return "L";
246 }
247 return "S";
248};
249
250export const isMapSchemaType = (schema: S.Schema<any>) =>
251 isMapSchema(schema) ||
252 isRecordSchema(schema) ||
253 isStructSchema(schema) ||
254 isClassSchema(schema) ||
255 false;
256
257export const isStringSetSchema = (schema: S.Schema<any>) => {
258 if (!isSetSchema(schema)) return false;
259 const valueAST = getSetValueAST(schema);
260 return valueAST !== undefined && AST.isString(valueAST);
261};
262
263export const isNumberSetSchema = (schema: S.Schema<any>) => {
264 if (!isSetSchema(schema)) return false;
265 const valueAST = getSetValueAST(schema);
266 return valueAST !== undefined && AST.isNumber(valueAST);
267};