@naanlang/naan 1.0.6 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naanlang/naan",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "author": "Richard C. Zulch",
5
5
  "description": "Naan™ software platform",
6
6
  "main": "./lib/core/naanlib.js",
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "scripts": {
14
14
  "start": "node lib/node_repl.js",
15
+ "debug": "node --inspect-brk lib/node_repl.js",
15
16
  "test": "node test/node_test.js"
16
17
  }
17
18
  }
@@ -515,6 +515,8 @@ closure DynaConverter(local conv) {
515
515
  closure DynaTable(dyna, tablename, local table) {
516
516
  table = new(object, this)
517
517
  table.name = tablename
518
+ if dyna.ycdb
519
+ table.ycdb = true // Yandex Cloud version of DynamoDB
518
520
 
519
521
  // info
520
522
  //
@@ -665,7 +667,7 @@ closure DynaTable(dyna, tablename, local table) {
665
667
  // Make a dynamoDB key dictionary for the table using the specified primary key, which can be a
666
668
  // tuple of hash/range or just a hash if only a hash is needed for this table.
667
669
  //
668
- conv.makeDynKey = function makeDynKey(primaryKey, local hashValue, rangeValue, key) {
670
+ table.makeDynKey = function makeDynKey(primaryKey, local hashValue, rangeValue, key) {
669
671
  if tuple(primaryKey)
670
672
  `(hashValue, rangeValue) = primaryKey
671
673
  else
@@ -994,14 +996,22 @@ closure DynamoDB(local dyna) {
994
996
 
995
997
  // login
996
998
  //
997
- dyna.login = function login(creds) {
998
- dyna.aws = xnew(awsSDK.DynamoDB, {
999
- region: creds.region
1000
- credentials: {
999
+ dyna.login = function login(creds, local params) {
1000
+ if !awsSDK
1001
+ return (list(Error("DynamoDB.login: no AWS SDK available")))
1002
+ params = { }
1003
+ if creds {
1004
+ params.region = creds.region
1005
+ params.credentials = {
1001
1006
  accessKeyId: creds.keyID
1002
1007
  secretAccessKey: creds.keySecret
1003
1008
  }
1004
- })
1009
+ if creds.dyna_endpoint {
1010
+ params.endpoint = creds.dyna_endpoint
1011
+ dyna.ycdb = true
1012
+ }
1013
+ }
1014
+ dyna.aws = xnew(awsSDK.DynamoDB, params)
1005
1015
  list(false, { ok: true })
1006
1016
  }
1007
1017
 
@@ -1011,6 +1021,8 @@ closure DynamoDB(local dyna) {
1011
1021
  // yet exist; see DynaTable for methods.
1012
1022
  //
1013
1023
  dyna.table = function table(name) {
1024
+ if !dyna.aws
1025
+ debuglog("DynamoDB.table: database not open:", name)
1014
1026
  DynaTable(dyna, name)
1015
1027
  }
1016
1028
 
@@ -37,7 +37,7 @@ function dynexBatchGetRecords(dyna, request,
37
37
  request = new(request) // allow local modifications
38
38
  index = { }
39
39
  for titem in request { // preflight all tables
40
- tname = titem.table.tablename
40
+ tname = titem.table.name
41
41
  if index[tname]
42
42
  return (Error("DynaTable.batchGetRecords: duplicate table in request:", tname))
43
43
  `(error) = titem.table.info()
@@ -57,7 +57,7 @@ function dynexBatchGetRecords(dyna, request,
57
57
  activity = false
58
58
  for `(tname, titem) in index {
59
59
  if titem.keys.length > 0 {
60
- primary = titem.table.makeDynKey(titem.keys.unshift())
60
+ primary = titem.table.makeDynKey(titem.keys.shift())
61
61
  if params.RequestItems[tname].Keys
62
62
  params.RequestItems[tname].Keys.push(primary)
63
63
  else
@@ -81,7 +81,7 @@ function dynexBatchGetRecords(dyna, request,
81
81
  dyna.aws.batchGetItem(params, function(err, data) {
82
82
  if err
83
83
  debuglog("aws.batchGetItem failed", err)
84
- pending.signal(err, data)
84
+ pending.signal(list(err, data))
85
85
  })
86
86
  `(err, response) = pending.wait()
87
87
  for `(tname, table) in response.Responses
@@ -124,7 +124,8 @@ function dynexBatchGetRecords(dyna, request,
124
124
  *
125
125
  */
126
126
 
127
- closure dynexBatchWriteRecords(dyna, request, local batches, error, activity) {
127
+ closure dynexBatchWriteRecords(dyna, request,
128
+ local index, group, tname, batches, error, activity) {
128
129
  if !dyna.aws
129
130
  return (list(Error("DynaTable.batchWriteRecords: not logged into AWS")))
130
131
 
@@ -132,19 +133,18 @@ closure dynexBatchWriteRecords(dyna, request, local batches, error, activity) {
132
133
  // addOp
133
134
  // Add a put or delete operation to the parameters
134
135
  //
135
- function addOp(params, table, del, record, local request) {
136
+ function addOp(params, table, del, record, primary, local request) {
136
137
  if del { // deleting a record
137
- if tuple(record)
138
- record = table.makeDynKey(record) // caller provided hash/range key pair
139
- else
140
- request = { DeleteRequest: { Key: dyna.conv.recordNaanToDyn(record) } }
138
+ if !tuple(record)
139
+ record = list(record[table.hashKey], record[table.rangeKey])
140
+ request = { DeleteRequest: { Key: table.makeDynKey(record) } }
141
141
  }
142
142
  else // putting a record
143
143
  request = { PutRequest: { Item: dyna.conv.recordNaanToDyn(record) } }
144
- if params.RequestItems[table.tablename]
145
- params.RequestItems[table.tablename].push(request)
144
+ if params.RequestItems[table.name]
145
+ params.RequestItems[table.name].push(request)
146
146
  else
147
- params.RequestItems[table.tablename] = [request]
147
+ params.RequestItems[table.name] = [request]
148
148
  }
149
149
 
150
150
  // transform into batches
@@ -155,48 +155,68 @@ closure dynexBatchWriteRecords(dyna, request, local batches, error, activity) {
155
155
  // request have no more entries.
156
156
  //
157
157
  request = new(request) // allow local modifications
158
+ index = { }
159
+ for group in request { // preflight all tables
160
+ tname = group.table.name
161
+ if index[tname]
162
+ return (Error("DynaTable.dynexBatchWriteRecords: duplicate table in request:", tname))
163
+ `(error) = group.table.info()
164
+ if error
165
+ return (Error("DynaTable.dynexBatchWriteRecords: table", tname, ":", error))
166
+ index[tname] = group
167
+ if group.put // ### in case these are xobjects
168
+ group.put = new(group.put) // new() doesn't deep clone them
169
+ if group.delete
170
+ group.delete = new(group.delete)
171
+ }
172
+ request = new(request) // allow local modifications
158
173
  batches = []
159
174
  activity = true
160
175
  while activity
161
- let (params, batchsize, table) {
176
+ let (params, batchsize) {
162
177
  params = {
163
178
  RequestItems: { }
164
179
  }
165
180
  batchsize = 0
166
181
  while :fillbatch activity { // loop until no activity or batch full
167
182
  activity = false
168
- for table in request {
169
- if table.delete.length > 0
170
- addOp(params, table, true, table.delete.unshift())
171
- else if table.put.length > 0
172
- addOp(params, table, false, table.put.unshift())
173
- else
174
- continue
175
- activity = true
176
- if ++batchsize >= 25
183
+ for group in request {
184
+ if group.delete.length > 0 {
185
+ addOp(params, group.table, true, group.delete.shift())
186
+ activity = true
187
+ ++batchsize
188
+ }
189
+ if group.put.length > 0 {
190
+ addOp(params, group.table, false, group.put.shift())
191
+ activity = true
192
+ ++batchsize
193
+ }
194
+ if batchsize >= 25
177
195
  break :fillbatch // our batch is full
178
196
  }
179
197
  }
180
198
  if batchsize > 0
181
199
  batches.push(params)
182
200
  } ()
183
- asyncArray(batches, 10, closure bwr(params, local delayer, pending, err, response) {
201
+ asyncArray(batches, 1, closure bwr(params, local delayer, pending, err, response) {
184
202
  delayer = dyna.conv.genBackoffDelayer()
185
203
  loop {
186
204
  pending = new(nonce)
187
- dyna.aws.batchWriteItem(params, function(err, data) {
205
+ dyna.aws.batchWriteItem(params, closure(err, data) {
188
206
  if err
189
207
  debuglog("aws.batchWriteItem failed", err)
190
- pending.signal(err, data)
208
+ pending.signal(list(err, data))
191
209
  })
192
210
  `(err, response) = pending.wait()
193
211
  if response.UnprocessedKeys
194
212
  params.RequestItems = response.UnprocessedKeys // still more to do
195
213
  else
196
214
  break // total success or failure
215
+ debuglog("dynexBatchWriteRecords unprocessedKeys:", response.UnprocessedKeys)
197
216
  if delayer() > dyna.timeout {
198
217
  if !error // too slow
199
218
  error = err
219
+ debuglog("dynexBatchWriteRecords timeout", error, err)
200
220
  break
201
221
  }
202
222
  }
@@ -235,7 +255,7 @@ closure dynexTransactGetRecords(dyna, request,
235
255
  request = new(request) // allow local modifications
236
256
  index = { }
237
257
  for titem in request { // preflight all tables
238
- tname = titem.table.tablename
258
+ tname = titem.table.name
239
259
  if index[tname]
240
260
  return (Error("DynaTable.batchGetRecords: duplicate table in request:", tname))
241
261
  `(error) = titem.table.info()
@@ -252,7 +272,7 @@ closure dynexTransactGetRecords(dyna, request,
252
272
  params.TransactItems.push({
253
273
  Get: {
254
274
  TableName: tname
255
- Key: makeDynKey(titem.keys.unshift())
275
+ Key: makeDynKey(titem.keys.shift())
256
276
  }
257
277
  })
258
278
  }
@@ -262,7 +282,7 @@ closure dynexTransactGetRecords(dyna, request,
262
282
  dyna.aws.transactGetItems(params, function(err, data) {
263
283
  if err
264
284
  debuglog("aws.transactGetItems failed", err)
265
- pending.signal(err, data)
285
+ pending.signal(list(err, data))
266
286
  })
267
287
  `(error, response) = pending.wait()
268
288
  if error
@@ -13,17 +13,36 @@
13
13
 
14
14
  /*
15
15
  * S3bucket
16
- *
17
- * S3 buckets.
16
+ *
17
+ * Bucket ooptions:
18
+ * {
19
+ * cacheControl: "max-age=2592000" - default cache control, e.g. to "max-age=2592000"
20
+ * }
18
21
  *
19
22
  */
20
23
 
21
- closure S3bucket(s3, name, local bucket) {
24
+ closure S3bucket(s3, name, bucketOptions, local bucket) {
22
25
  bucket = new(object, this)
23
26
  bucket.name = name
24
-
27
+
28
+ //
29
+ // head
30
+ //
31
+ bucket.head = closure head(key, local pending) {
32
+ pending = new(nonce)
33
+ s3.aws.headObject({
34
+ Bucket: bucket.name
35
+ Key: key
36
+ }, function (error, data) {
37
+ if error
38
+ error = Error("S3bucket.head failed:", error.name, "key:", key, "in", bucket.name)
39
+ pending.signal(list(error, data))
40
+ })
41
+ pending.wait()
42
+ }
43
+
25
44
  //
26
- // list
45
+ // listObjects
27
46
  //
28
47
  bucket.listObjects = closure listObjects(local pending) {
29
48
  pending = new(nonce)
@@ -31,31 +50,46 @@ closure S3bucket(s3, name, local bucket) {
31
50
  Bucket: bucket.name
32
51
  }, function (error, data) {
33
52
  if error
34
- error = Error("S3bucket.list failed", error)
53
+ error = Error("S3bucket.list failed:", error.name, "for", bucket.name)
35
54
  pending.signal(list(error, data))
36
55
  })
37
56
  pending.wait()
38
57
  }
39
58
 
40
59
  //
41
- // upload
60
+ // put
42
61
  //
43
- bucket.upload = closure upload(key, data, local pending) {
44
- pending = new(nonce)
45
- s3.aws.upload({
62
+ // options:
63
+ // {
64
+ // contentType: "text/plain" - override contentType if specified
65
+ // cacheControl: "max-age=2592000" - default cache control, e.g. to "max-age=2592000"
66
+ // metadata: { <items> } - optional dictionary of keys to store
67
+ // }
68
+ //
69
+ bucket.put = closure put(key, data, options, local params, pending) {
70
+ if !contentType
71
+ contentType = "application/octet-stream"
72
+ params = {
46
73
  Bucket: bucket.name
47
- ACL: "public-read"
48
- Body: data
49
74
  Key: key
50
- ContentType: "application/octet-stream" // force download from browser
51
- })
52
- .on("httpUploadProgress", function(evt) {
53
- debuglog("upload progress", totuple(evt))
54
- })
55
- .send(function(error, data) {
75
+ ContentMD5: HashMD5_base64(data)
76
+ Body: data
77
+ }
78
+ if options.contentType
79
+ params.ContentType = options.contentType
80
+ if options.metadata
81
+ params.Metadata = options.metadata
82
+ else
83
+ params.Metadata = "application/octet-stream"
84
+ if options.cacheControl
85
+ params.CacheControl = options.cacheControl
86
+ else if bucket.cacheControl
87
+ params.CacheControl = bucket.cacheControl
88
+ pending = new(nonce)
89
+ s3.aws.putObject(params, function(error, resp) {
56
90
  if error
57
- error = Error("S3bucket.upload failed", error)
58
- pending.signal(list(error, data))
91
+ error = Error("S3bucket.put failed:", error.name, "key:", key, "in", bucket.name)
92
+ pending.signal(list(error, resp))
59
93
  })
60
94
  pending.wait()
61
95
  }
@@ -88,6 +122,8 @@ closure S3(local s3) {
88
122
  accessKeyId: creds.keyID
89
123
  secretAccessKey: creds.keySecret
90
124
  }
125
+ if creds.endpoint
126
+ params.endpoint = creds.endpoint
91
127
  }
92
128
  s3.aws = xnew(awsSDK.S3, params)
93
129
  if awsSDK.S3Client
@@ -111,12 +147,23 @@ closure S3(local s3) {
111
147
  // This obtains a pre-signed URL for the specified command and its parameters. Options control
112
148
  // how the URL is generated.
113
149
  //
150
+ // CommandParams:
151
+ // {
152
+ // Bucket: <string> // bucket name
153
+ // Key: <string> // object being operated upon
154
+ // ContentType: <string> // video/mp4 or whatever
155
+ // ResponseCacheControl: <string> // reportedly for getObject only
156
+ // ACL: <string> // e.g. "public-read"
157
+ // Body: <data> // fiik
158
+ // }
159
+ //
114
160
  // Options:
115
161
  // {
116
- // expiresIn: <seconds> // default expiration is 900 (15 minutes)
162
+ // expiresIn: <seconds> // default expiration is 900 (15 minutes)
163
+ // signingDate: <epoch-secs> // reference time for URL being created
117
164
  // }
118
165
  //
119
- s3.getSignedURL = closure getSignedURL(cmdname, cmdparams, local command, options, pending) {
166
+ s3.getSignedURL = closure getSignedURL(cmdname, cmdparams, options, local command, pending) {
120
167
  if s3.client {
121
168
  if cmdname == "putObject"
122
169
  cmdname = "PutObjectCommand"
@@ -124,16 +171,15 @@ closure S3(local s3) {
124
171
  cmdname = "GetObjectCommand"
125
172
  else
126
173
  return (list(Error("S3bucket.getSignedURL: command not present:", cmdname)))
127
- if cmdparams.Expires {
128
- cmdparams = new(cmdparams)
129
- options.expiresIn = cmdparams.Expires
130
- cmdparams.Expires = undefined
131
- } else
132
- options = { }
133
174
  command = xnew(awsSDK.s3Commands[cmdname], cmdparams)
134
175
  await(awsSDK.getSignedUrl(s3.client, command, options))
135
176
  } else {
177
+ debuglog("s3.getSignedURL: warning: old API")
136
178
  pending = new(nonce)
179
+ if options.expiresIn {
180
+ cmdparams = new(cmdparams)
181
+ cmdparams.Expires = options.expiresIn
182
+ }
137
183
  s3.aws.getSignedUrl(cmdname, cmdparams, function(error, data) {
138
184
  pending.signal(list(error, data))
139
185
  })
@@ -37,6 +37,19 @@ closure dawsBucket(s3b, local bucket, s3) {
37
37
  closure asyncResult args {
38
38
  future(function() { apply(pop(args), args) }).run()
39
39
  }
40
+
41
+ // hashFromETag
42
+ //
43
+ // Return the MD5 hash for an ETag
44
+ //
45
+ function hashFromETag(etag) {
46
+ if etag.startsWith("W/")
47
+ etag = etag.slice(2) // "weak" from gzip with NGINX, apparently
48
+ if etag.length == 34 // multipart S3 ETag will is longer and will be ignored
49
+ etag.substring(1,33)
50
+ else
51
+ undefined
52
+ }
40
53
 
41
54
  // close
42
55
  //
@@ -65,8 +78,8 @@ closure dawsBucket(s3b, local bucket, s3) {
65
78
  function s3GetCB(error, resp) {
66
79
  if error
67
80
  error = Error("can't get object", error, key)
68
- else if resp.ETag.length == 34
69
- resp.md5 = resp.ETag.substring(1,33) // ETags have to have quotes, but our hash doesn't
81
+ else
82
+ resp.md5 = hashFromETag(resp.ETag)
70
83
  callback(error, resp)
71
84
  }
72
85
 
@@ -79,12 +92,18 @@ closure dawsBucket(s3b, local bucket, s3) {
79
92
  // put
80
93
  //
81
94
  // Put an object in the bucket. Parameters include:
82
- // key - the object name, which may not be empty or start with "/"
83
- // data - optional content to store
84
- // metadata - optional dictionary of keys to store
95
+ // key - the object name, which may not be empty or start with "/"
96
+ // data - optional content to store
97
+ // options:
98
+ // {
99
+ // contentType: "text/plain" - override contentType if specified
100
+ // cacheControl: "max-age=2592000" - default cache control, e.g. to "max-age=2592000"
101
+ // metadata: { <items> } - optional dictionary of keys to store
102
+ // }
85
103
 
86
- bucket.put = closure put(key, data, metadata, callback, local params, mimeType, md5) {
87
- if !string(key) || length(key) == 0 || key.substring(0,1) == "/" || metadata && !dictionary(metadata)
104
+ bucket.put = closure put(key, data, options, callback,
105
+ local params, mimeType, md5) {
106
+ if !string(key) || length(key) == 0 || key.substring(0,1) == "/"
88
107
  return (asyncResult(callback, Error("invalid arguments")))
89
108
  if !s3
90
109
  return (asyncResult(callback, Error("database closed")))
@@ -128,9 +147,13 @@ closure dawsBucket(s3b, local bucket, s3) {
128
147
  mimeType = mimeType.concat("; charset=UTF-8")
129
148
  params.ContentType = mimeType
130
149
  }
150
+ if options.contentType
151
+ params.ContentType = options.contentType
131
152
  }
132
- if metadata
133
- params.Metadata = metadata
153
+ if options.metadata
154
+ params.Metadata = options.metadata
155
+ if options.cacheControl
156
+ params.CacheControl = options.cacheControl
134
157
  if data
135
158
  params.Body = data
136
159
  else
@@ -142,11 +165,10 @@ closure dawsBucket(s3b, local bucket, s3) {
142
165
  error = Error("can't put object", error, key)
143
166
  else {
144
167
  resp.length = length(data)
145
- if resp.ETag.length == 34 { // multipart S3 ETag will is longer and will be ignored
146
- resp.md5 = resp.ETag.substring(1,33) // ETags have to have quotes, but our hash doesn't
147
- if md5 != resp.md5 {
148
- error = Error("hash match failure", md5, resp.md5) // has match failed on round-trip
149
- resp = false } } }
168
+ resp.md5 = hashFromETag(resp.ETag)
169
+ if resp.md5 && md5 != resp.md5 {
170
+ error = Error("hash match failure", md5, resp.md5) // has match failed on round-trip
171
+ resp = false } }
150
172
  callback(error, resp)
151
173
  })
152
174
  }
@@ -285,12 +307,31 @@ closure dawsBucket(s3b, local bucket, s3) {
285
307
  *
286
308
  */
287
309
 
288
- closure dawsTable(database, path, local table) {
310
+ closure dawsTable(database, path, tableOptions, local table) {
289
311
  table = new(object, this)
290
312
  table.type = database.type
291
313
  table.db = database
292
314
  table.path = path
293
315
  table.s3prefix = makePrefix(path)
316
+
317
+ // readableStreamToArrayBuffer
318
+ //
319
+ // For browsers, convert a ReadableStream to an array buffer.
320
+ //
321
+ function readableStreamToArrayBuffer(stream, local result, reader, error, data, chunk) {
322
+ result = xnew(js.w.Uint8Array, 0)
323
+ reader = stream.getReader()
324
+ loop {
325
+ `(error, data) = await(reader.read())
326
+ if error || data.done
327
+ break
328
+ chunk = xnew(js.w.Uint8Array, result.length + data.value.length)
329
+ chunk.set(result)
330
+ chunk.set(data.value, result.length)
331
+ result = chunk
332
+ }
333
+ result
334
+ }
294
335
 
295
336
  // makePrefix
296
337
  //
@@ -344,7 +385,7 @@ closure dawsTable(database, path, local table) {
344
385
  if resp.ContentType
345
386
  doc.contentType = resp.ContentType
346
387
  if resp.md5
347
- doc.md5 = resp.md5
388
+ doc._md5 = resp.md5
348
389
  doc
349
390
  }
350
391
 
@@ -394,11 +435,14 @@ closure dawsTable(database, path, local table) {
394
435
  if content
395
436
  doc.content = content
396
437
  metadata = metadata2s3(metadata) // filter out any invalid metadata
397
- database.bucket.put(id, content, metadata, function(error, resp) {
438
+ database.bucket.put(id, content, {
439
+ metadata: metadata
440
+ cacheControl: tableOptions.cacheControl
441
+ }, function(error, resp) {
398
442
  if error
399
443
  error = Error("add failed", error, id, { status: error.$metadata.httpStatusCode })
400
444
  else {
401
- doc.md5 = resp.md5 // update the original doc
445
+ doc._md5 = resp.md5 // update the original doc
402
446
  doc.length = resp.length }
403
447
  callback(error, doc)
404
448
  })
@@ -411,14 +455,17 @@ closure dawsTable(database, path, local table) {
411
455
  table.update = closure update(doc, callback, local metadata) {
412
456
  if !callback
413
457
  return (syncAdapter(update, doc))
414
- if !doc._id || !doc._id.startsWith(table.s3prefix) || !doc.md5
458
+ if !doc._id || !doc._id.startsWith(table.s3prefix) || !doc._md5
415
459
  return (asyncResult(callback, Error("invalid argument")))
416
460
  metadata = metadata2s3(doc)
417
- database.bucket.put(doc._id, doc.content, metadata, function(error, resp) {
461
+ database.bucket.put(doc._id, doc.content, {
462
+ metadata: metadata
463
+ cacheControl: tableOptions.cacheControl
464
+ }, function(error, resp) {
418
465
  if error
419
466
  error = Error("update failed", error, doc._id, { status: error.$metadata.httpStatusCode })
420
467
  else {
421
- doc.md5 = resp.md5 // update the original doc
468
+ doc._md5 = resp.md5 // update the original doc
422
469
  doc.modified = undefined // we don't know exact modify date
423
470
  doc.length = resp.length }
424
471
  callback(error, doc)
@@ -433,7 +480,7 @@ closure dawsTable(database, path, local table) {
433
480
  table.delete = closure delete(doc, callback) {
434
481
  if !callback
435
482
  return (syncAdapter(delete, doc))
436
- if !doc._id || !doc._id.startsWith(table.s3prefix) || !doc.md5
483
+ if !doc._id || !doc._id.startsWith(table.s3prefix) || !(doc._md5 || doc._id.slice(-1) == "/")
437
484
  return (asyncResult(callback, Error("invalid argument")))
438
485
  database.bucket.delete(doc._id, function(error, resp) {
439
486
  if error
@@ -469,6 +516,8 @@ closure dawsTable(database, path, local table) {
469
516
  { } // already "decoded", e.g. base64
470
517
  else if jsTypedArray(resp.content)
471
518
  resp.content = xnew(TextDecoder, "utf-8").decode(resp.content)
519
+ else if jsInstanceOf(resp.content, js.w.ReadableStream)
520
+ resp.content = xnew(TextDecoder, "utf-8").decode(readableStreamToArrayBuffer(resp.content))
472
521
  resp._id = id }
473
522
  callback(error, resp)
474
523
  })
@@ -748,12 +797,12 @@ closure S3DB(s3b, local database, result) {
748
797
  //
749
798
  // Return a table-access object for the table.
750
799
 
751
- database.table = function table(path) {
800
+ database.table = function table(path, options) {
752
801
  if !database.bucket
753
802
  return (list(Error("database closed")))
754
803
  if !string(path)
755
804
  return (list(Error("invalid table path")))
756
- dawsTable(database, path)
805
+ dawsTable(database, path, options)
757
806
  }
758
807
 
759
808
  // finis